From 24db4d7d6ef5247ad8de3b49636aae98b0c98bfa Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Thu, 8 Oct 2026 23:22:04 -0700 Subject: [PATCH] chore: improve CLI maintainability and test reliability --- .github/workflows/tests.yml | 48 + docs/CHANGELOG.md | 20 + docs/cli-maintainability.md | 27 + docs/code-readability.md | 21 + docs/testing.md | 81 + scripts/verify-component-stability.ps1 | 18 + scripts/verify-targeted-mutations.py | 24 + .../Components/AgentGitChanges.razor | 6 +- .../Components/AgentSettingsPanel.razor | 23 +- src/MandoCode/Components/App.Attachments.cs | 131 + src/MandoCode/Components/App.Commands.cs | 314 +++ src/MandoCode/Components/App.Integrations.cs | 197 ++ src/MandoCode/Components/App.Lifecycle.cs | 291 ++ src/MandoCode/Components/App.Planning.cs | 940 +++++++ src/MandoCode/Components/App.Requests.cs | 318 +++ .../Components/App.TerminalEffects.cs | 266 ++ src/MandoCode/Components/App.razor | 2496 +---------------- src/MandoCode/Components/ChatShell.razor | 65 +- .../Components/ContextSnapshotsPanel.razor | 56 +- .../Components/IntegrationSettingsPanel.razor | 1570 ++++++++--- .../Components/OllamaPullPanel.razor | 30 +- src/MandoCode/Components/PromptInput.razor | 6 +- .../Components/SetupWizardPanel.razor | 4 +- .../Services/Agents/CliAgentInteractions.cs | 137 +- src/MandoCode/Services/Ai/AIService.cs | 227 +- .../Services/Ai/OllamaModelInspector.cs | 30 + .../Services/Ai/PendingImageEvidence.cs | 54 + .../Services/Ai/ResponseLimitNotice.cs | 55 + src/MandoCode/Services/Commands/CliCommand.cs | 17 + .../Services/Commands/CliReferenceCommands.cs | 42 + .../Services/Input/AgentKeyboardRouter.cs | 14 + .../Rendering/AgentPresentationState.cs | 35 + .../Rendering/CallbackRegistrations.cs | 22 + .../Services/Rendering/SpinnerService.cs | 295 +- .../Rendering/TerminalActivityPresentation.cs | 267 ++ .../Sessions/ConversationHistoryCodec.cs | 37 + tests/MandoCode.Tests/AgentArchiveTests.cs | 1 + tests/MandoCode.Tests/AgentAvatarTests.cs | 1 + .../AgentExchangeLayoutTests.cs | 1 + tests/MandoCode.Tests/AgentFileTreeTests.cs | 1 + .../AgentFunctionMiddlewareLifecycleTests.cs | 1 + ...tFunctionMiddlewarePendingPlanGateTests.cs | 1 + ...FunctionMiddlewarePlanCancellationTests.cs | 1 + ...gentFunctionMiddlewareWebCallDedupTests.cs | 1 + .../AgentHistoryKeyboardTests.cs | 1 + .../AgentModelDefaultsTests.cs | 1 + .../AgentSettingsDraftTests.cs | 1 + .../AgentSettingsKeyboardTests.cs | 1 + tests/MandoCode.Tests/AgentToolNamingTests.cs | 23 +- tests/MandoCode.Tests/AgentWorkspaceTests.cs | 20 +- .../ApprovalPromptGateTests.cs | 9 +- tests/MandoCode.Tests/ApprovalSelectTests.cs | 1 + .../CliAgentInteractionTests.cs | 1 + tests/MandoCode.Tests/CliAgentMentionTests.cs | 1 + .../CliIntegrationCoordinatorTests.cs | 1 + .../MandoCode.Tests/ClipboardCommandTests.cs | 1 + tests/MandoCode.Tests/ClipboardImageTests.cs | 1 + .../CommandAutocompleteTests.cs | 1 + .../MandoCode.Tests/CommandOutputSinkTests.cs | 1 + tests/MandoCode.Tests/CommandPreviewTests.cs | 1 + tests/MandoCode.Tests/ConfigKeySetterTests.cs | 1 + .../MandoCode.Tests/ConfigMenuHistoryTests.cs | 1 + .../ConfigurationWizardPromptTests.cs | 1 + tests/MandoCode.Tests/ContextMeterTests.cs | 1 + tests/MandoCode.Tests/ContextSnapshotTests.cs | 1 + .../ContextSnapshotsPanelTests.cs | 1 + tests/MandoCode.Tests/DiffServiceTests.cs | 1 + .../MandoCode.Tests/DirectoryBrowserTests.cs | 1 + .../MandoCode.Tests/ExplicitPlanningTests.cs | 1 + .../ExplorerFileReferenceTests.cs | 1 + .../ExplorerFocusScopeTests.cs | 1 + .../ExplorerPromptLayoutTests.cs | 1 + .../FallbackFunctionCallExecutorTests.cs | 1 + .../FileAutocompleteAsyncTests.cs | 1 + .../FileAutocompleteLimitTests.cs | 1 + .../FileReferenceScopeTests.cs | 1 + .../FileSystemPluginRangedReadTests.cs | 1 + .../MandoCode.Tests/FileSystemPluginTests.cs | 1 + .../GitChangesConfirmationTests.cs | 1 + .../GitChangesKeyboardTests.cs | 10 +- .../MandoCode.Tests/GitChangesServiceTests.cs | 1 + .../ImageFileReferenceTests.cs | 1 + .../ImageInputDeliveryTests.cs | 1 + .../MandoCode.Tests/InputStateMachineTests.cs | 5 + .../InstructionPromptCoordinatorTests.cs | 1 + .../IntegrationSettingsKeyboardTests.cs | 1 + tests/MandoCode.Tests/InvocationScopeTests.cs | 1 + .../LengthCutoffNoticeTests.cs | 1 + tests/MandoCode.Tests/LiveTranscriptTests.cs | 1 + .../MandoCodeConfigOnboardingTests.cs | 1 + tests/MandoCode.Tests/MandoCodeConfigTests.cs | 1 + .../MarkdownHtmlRendererTests.cs | 1 + tests/MandoCode.Tests/McpApprovalGateTests.cs | 1 + tests/MandoCode.Tests/McpConfigTests.cs | 1 + tests/MandoCode.Tests/ModelPickerTests.cs | 1 + .../ModelVisionCapabilitiesTests.cs | 1 + .../MultiAgentExplorerTests.cs | 34 +- .../MandoCode.Tests/NumCtxHttpHandlerTests.cs | 1 + .../OllamaConnectionRecoveryTests.cs | 1 + .../OllamaModelAvailabilityTests.cs | 1 + .../OllamaModelLibraryTests.cs | 1 + .../OllamaPullKeyboardTests.cs | 1 + .../MandoCode.Tests/OllamaSetupHelperTests.cs | 1 + .../PartialTraceAccumulationTests.cs | 1 + tests/MandoCode.Tests/PlanAcceptanceTests.cs | 1 + .../PlanCheckpointEnvelopeTests.cs | 1 + .../PlanCheckpointStoreTests.cs | 1 + tests/MandoCode.Tests/PlanChoiceTests.cs | 1 + tests/MandoCode.Tests/PlanExecutorIdsTests.cs | 1 + .../MandoCode.Tests/PlanFinalQualityTests.cs | 1 + tests/MandoCode.Tests/PlanHandoffTests.cs | 1 + .../MandoCode.Tests/PlanProposalSlotTests.cs | 1 + .../PlanQualityOutcomeTests.cs | 1 + tests/MandoCode.Tests/PlanRedirectionTests.cs | 1 + tests/MandoCode.Tests/PlanReliabilityTests.cs | 1 + .../PlanRepositoryContextTests.cs | 1 + .../MandoCode.Tests/PlanResumeContextTests.cs | 1 + tests/MandoCode.Tests/PlanRevisionTests.cs | 1 + tests/MandoCode.Tests/PlanRunStateTests.cs | 1 + .../PlanRunnerBehaviorTests.cs | 72 +- tests/MandoCode.Tests/PlanStepContextTests.cs | 1 + tests/MandoCode.Tests/PlanStepEditorTests.cs | 1 + .../MandoCode.Tests/PlanStepRecoveryTests.cs | 1 + tests/MandoCode.Tests/PlanStepReportTests.cs | 1 + tests/MandoCode.Tests/PlanStepRetryTests.cs | 1 + .../PlanWorkflowTopologyTests.cs | 20 +- .../PlannerEngineConfigTests.cs | 1 + .../PresentationBoundaryTests.cs | 61 + .../MandoCode.Tests/PreviewToolPolicyTests.cs | 1 + .../PromptComposerStateTests.cs | 1 + .../PromptCursorNavigationTests.cs | 1 + .../PromptEmojiAliasesTests.cs | 1 + .../MandoCode.Tests/PromptEmojiPickerTests.cs | 1 + tests/MandoCode.Tests/PromptPasteTests.cs | 1 + tests/MandoCode.Tests/ReplyPreviewTests.cs | 1 + tests/MandoCode.Tests/ReplyTextTests.cs | 1 + tests/MandoCode.Tests/RetryPolicyTests.cs | 1 + tests/MandoCode.Tests/SetupPromptTests.cs | 1 + .../SetupWizardKeyboardTests.cs | 1 + tests/MandoCode.Tests/SetupWorkflowTests.cs | 1 + .../ShellCommandClassifierTests.cs | 1 + .../MandoCode.Tests/ShellEnvironmentTests.cs | 36 +- tests/MandoCode.Tests/SkillAuthorTests.cs | 1 + .../SkillManagementStoreTests.cs | 1 + tests/MandoCode.Tests/StepNarrationTests.cs | 1 + tests/MandoCode.Tests/StreamBufferingTests.cs | 1 + .../MandoCode.Tests/SyntaxHighlighterTests.cs | 1 + tests/MandoCode.Tests/SystemPromptsTests.cs | 1 + .../TaskPlannerServiceTests.cs | 11 + .../TerminalEmojiPresentationTests.cs | 1 + .../TokenTrackingServiceTests.cs | 1 + .../MandoCode.Tests/ToolActivityIconsTests.cs | 1 + tests/MandoCode.Tests/ToolActivityTests.cs | 1 + .../MandoCode.Tests/ToolUpdateServiceTests.cs | 1 + .../MandoCode.Tests/TranscriptExportTests.cs | 1 + .../MandoCode.Tests/TranscriptLookupTests.cs | 1 + tests/MandoCode.Tests/TuiLayoutTests.cs | 1 + .../MandoCode.Tests/TuiOutputRoutingTests.cs | 1 + tests/MandoCode.Tests/TuiStatusTests.cs | 1 + tests/MandoCode.Tests/VersionLabelTests.cs | 1 + .../MandoCode.Tests/WidgetRendererHarness.cs | 58 + .../MandoCode.Tests/WorkspaceRegistryTests.cs | 1 + 162 files changed, 5280 insertions(+), 3377 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 docs/cli-maintainability.md create mode 100644 docs/code-readability.md create mode 100644 docs/testing.md create mode 100644 scripts/verify-component-stability.ps1 create mode 100644 scripts/verify-targeted-mutations.py create mode 100644 src/MandoCode/Components/App.Attachments.cs create mode 100644 src/MandoCode/Components/App.Commands.cs create mode 100644 src/MandoCode/Components/App.Integrations.cs create mode 100644 src/MandoCode/Components/App.Lifecycle.cs create mode 100644 src/MandoCode/Components/App.Planning.cs create mode 100644 src/MandoCode/Components/App.Requests.cs create mode 100644 src/MandoCode/Components/App.TerminalEffects.cs create mode 100644 src/MandoCode/Services/Ai/OllamaModelInspector.cs create mode 100644 src/MandoCode/Services/Ai/PendingImageEvidence.cs create mode 100644 src/MandoCode/Services/Ai/ResponseLimitNotice.cs create mode 100644 src/MandoCode/Services/Commands/CliCommand.cs create mode 100644 src/MandoCode/Services/Commands/CliReferenceCommands.cs create mode 100644 src/MandoCode/Services/Input/AgentKeyboardRouter.cs create mode 100644 src/MandoCode/Services/Rendering/AgentPresentationState.cs create mode 100644 src/MandoCode/Services/Rendering/CallbackRegistrations.cs create mode 100644 src/MandoCode/Services/Rendering/TerminalActivityPresentation.cs create mode 100644 src/MandoCode/Services/Sessions/ConversationHistoryCodec.cs create mode 100644 tests/MandoCode.Tests/PresentationBoundaryTests.cs create mode 100644 tests/MandoCode.Tests/WidgetRendererHarness.cs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3e3a733 --- /dev/null +++ b/.github/workflows/tests.yml @@ -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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3f88043..6ee5e96 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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. diff --git a/docs/cli-maintainability.md b/docs/cli-maintainability.md new file mode 100644 index 0000000..3f97621 --- /dev/null +++ b/docs/cli-maintainability.md @@ -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. diff --git a/docs/code-readability.md b/docs/code-readability.md new file mode 100644 index 0000000..fb95a5d --- /dev/null +++ b/docs/code-readability.md @@ -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. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..9a65ceb --- /dev/null +++ b/docs/testing.md @@ -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. diff --git a/scripts/verify-component-stability.ps1 b/scripts/verify-component-stability.ps1 new file mode 100644 index 0000000..59faa1e --- /dev/null +++ b/scripts/verify-component-stability.ps1 @@ -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." } +} diff --git a/scripts/verify-targeted-mutations.py b/scripts/verify-targeted-mutations.py new file mode 100644 index 0000000..cd1ab85 --- /dev/null +++ b/scripts/verify-targeted-mutations.py @@ -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) diff --git a/src/MandoCode/Components/AgentGitChanges.razor b/src/MandoCode/Components/AgentGitChanges.razor index a2383ba..27122ae 100644 --- a/src/MandoCode/Components/AgentGitChanges.razor +++ b/src/MandoCode/Components/AgentGitChanges.razor @@ -73,6 +73,8 @@ private string _error = "", _root = ""; private bool _refreshing; private string _refreshMessage = ""; + internal Func 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; @@ -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(); } @@ -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) { } diff --git a/src/MandoCode/Components/AgentSettingsPanel.razor b/src/MandoCode/Components/AgentSettingsPanel.razor index f741c0d..5c1476d 100644 --- a/src/MandoCode/Components/AgentSettingsPanel.razor +++ b/src/MandoCode/Components/AgentSettingsPanel.razor @@ -9,14 +9,25 @@ @for (var i = 0; i < Groups.Length; i++) { var tab = i; -
+
} - + @if (_custom && _editing is { } field) { - + } else @@ -67,7 +78,11 @@ { var index = OptionCount + action; var label = Actions[action]; -
" : " ") + label)" Foreground="@(label == "Save" && !HasChanges ? Color.Grey42 : index == _selected ? Color.White : Color.Grey85)" Background="@(index == _selected && (label != "Save" || HasChanges) ? SelectionBackground : Color.Default)" />
+
" : " ") + label)" Foreground="@(label == "Save" && !HasChanges ? Color.Grey42 : index == _selected ? Color.White : Color.Grey85)" Background="@(index == _selected && (label != "Save" || HasChanges) ? SelectionBackground : Color.Default)" />
}
@foreach (var line in GlobalDescriptionLines) { } diff --git a/src/MandoCode/Components/App.Attachments.cs b/src/MandoCode/Components/App.Attachments.cs new file mode 100644 index 0000000..1ac6cad --- /dev/null +++ b/src/MandoCode/Components/App.Attachments.cs @@ -0,0 +1,131 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Components.Web; +using MandoCode.Models; +using MandoCode.Services; +using Spectre.Console; +using System.Text.RegularExpressions; + +namespace MandoCode.Components; + +public partial class App +{ + private string ProcessFileReferences(string input) + { + var matches = FileReferenceToken.Paths(input).ToArray(); + + if (matches.Length == 0) + return input; + + var contextBlocks = new System.Text.StringBuilder(); + var referencedFiles = new HashSet(); + int totalExpansionChars = 0; + int skippedForBudget = 0; + + foreach (var filePath in matches) + { + if (Pane is not null && CliAgentDirectory.IsAgentName(filePath) && new CliAgentDirectory(Pane).Resolve(filePath) is not null) continue; + + // Skip duplicates + if (!referencedFiles.Add(filePath)) + continue; + + if (ImageFileReference.IsImage(filePath)) + { + try + { + var clipboard = (Pane?.Services ?? Services).GetService(); + var image = clipboard?.TryGet(filePath, out var pasted) == true + ? (Bytes: pasted, MediaType: "image/png") + : ImageFileReference.Read(ProjectRoot.ProjectRoot, filePath); + if (!AI.TryAttachImage(image.Bytes, image.MediaType, $"Referenced image: {filePath}", out var error)) + throw new IOException(error); + contextBlocks.AppendLine($"Image: {filePath} β€” attached as image input. Inspect the supplied image directly, rather than reading its binary bytes as text."); + TuiConsole.MarkupLine($"πŸ“· {Spectre.Console.Markup.Escape(filePath)}"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + contextBlocks.AppendLine($"Image: {filePath} β€” not attached: {ex.Message} Do not attempt to infer the image contents from raw bytes."); + TuiConsole.MarkupLine($"[yellow]{Spectre.Console.Markup.Escape($"Image not attached: {filePath}: {ex.Message}")}[/]"); + } + continue; + } + + // Total-budget check β€” stop expanding once we've accumulated enough context. + if (totalExpansionChars >= MaxFileReferenceBudgetChars) + { + skippedForBudget++; + continue; + } + + var content = FileProvider.ReadFileContent(filePath); + if (content != null) + { + // Truncate files over 10,000 chars + if (content.Length > 10_000) + content = content.Substring(0, 10_000) + "\n... [truncated]"; + + contextBlocks.AppendLine($"File: {filePath}"); + contextBlocks.AppendLine("```"); + contextBlocks.AppendLine(content); + contextBlocks.AppendLine("```"); + contextBlocks.AppendLine(); + totalExpansionChars += content.Length; + + // Show consistent operation display for @file reads + var lineCount = content.Split('\n').Length; + var readDisplay = new OperationDisplayEvent + { + OperationType = "Read", + FilePath = filePath, + LineCount = lineCount + }; + OperationRenderer.Render(readDisplay); + } + else + { + // Check if this is a directory reference + var dirListing = FileProvider.GetDirectoryListing(filePath); + if (dirListing != null) + { + // Dir listings can be huge for big projects β€” cap at 4k chars per reference. + if (dirListing.Length > 4_000) + dirListing = dirListing.Substring(0, 4_000) + "\n... [truncated]"; + + contextBlocks.AppendLine($"Directory: {filePath}/"); + contextBlocks.AppendLine(FileAutocompleteProvider.BuildDirectoryScopeInstruction(filePath)); + contextBlocks.AppendLine("Contents:"); + contextBlocks.AppendLine(dirListing); + contextBlocks.AppendLine(); + totalExpansionChars += dirListing.Length; + + TuiConsole.MarkupLine($"[dim][[Directory]][/] [deepskyblue1]{Spectre.Console.Markup.Escape(filePath)}/[/]"); + } + else + { + TuiConsole.MarkupLine($"[dim][[Not found]][/] [yellow]{Spectre.Console.Markup.Escape(filePath)}[/]"); + } + } + } + + if (contextBlocks.Length == 0) + return input; + + if (skippedForBudget > 0) + { + contextBlocks.AppendLine($"[Note: {skippedForBudget} additional @reference(s) were skipped β€” the total expansion budget of {MaxFileReferenceBudgetChars:N0} chars was reached. Re-reference specific files in follow-up messages if needed.]"); + contextBlocks.AppendLine(); + TuiConsole.MarkupLine($"[yellow][[Skipped]][/] [dim]{skippedForBudget} @reference(s) beyond expansion budget ({MaxFileReferenceBudgetChars:N0} chars)[/]"); + } + + var fullContext = new System.Text.StringBuilder(); + fullContext.AppendLine("--- Referenced Files ---"); + fullContext.Append(contextBlocks); + fullContext.AppendLine("---"); + fullContext.AppendLine(); + fullContext.Append(input); + + return fullContext.ToString(); + } + +} diff --git a/src/MandoCode/Components/App.Commands.cs b/src/MandoCode/Components/App.Commands.cs new file mode 100644 index 0000000..31b0a8a --- /dev/null +++ b/src/MandoCode/Components/App.Commands.cs @@ -0,0 +1,314 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using MandoCode.Models; +using MandoCode.Services; +using Spectre.Console; + +namespace MandoCode.Components; + +// UI-owning commands stay with the component; reusable command operations live in Services/Commands. +public partial class App +{ + private async Task ExecuteSlashCommandAsync(string input) + { + var parsed = CliCommand.Parse(input); + var command = parsed.DispatchText; + + // Handle special commands + if (command == "update") + { + await HandleUpdateCommandAsync(); + if (ToolUpdater.Pending) return CliCommandResult.Exit; + return CliCommandResult.Continue; + } + if (command == "exit") + { + StopMusicVisualizer(); + MusicPlayer.Dispose(); + ThemeService.Dispose(); + TuiConsole.WriteLine("Goodbye!"); + Lifetime.StopApplication(); + return CliCommandResult.Exit; + } + + if (command == "compact") + { + if (await AI.CompactHistoryAsync()) + { + try { if (Pane is null || Pane.Id == 1) SessionResumeStore.Save(ProjectRoot.ProjectRoot, AI.ExportHistoryJson()); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { System.Diagnostics.Trace.TraceWarning("Could not save compacted context: {0}", ex.Message); } + SaveAgentHistory(false); + TuiConsole.MarkupLine("[green]Conversation context compacted into a recap. The visible transcript is unchanged.[/]"); + } + else + { + TuiConsole.MarkupLine("[dim]Not enough conversation context to compact yet.[/]"); + } + return CliCommandResult.Continue; + } + + if (command == "clear") + { + await AI.ClearHistoryAsync(); + StateMachine.ClearHistory(); + SessionResumeStore.Delete(ProjectRoot.ProjectRoot); // cleared means cleared β€” --continue too + TuiConsole.Clear(); + _messages.Clear(); + if (Pane is not null) Pane.Services.GetRequiredService().Delete(Pane.PersistKey); + TuiConsole.MarkupLine("[yellow]Conversation context wiped completely. Start a new conversation.[/]"); + return CliCommandResult.Continue; + } + + if (command == "plan-resume") + { + await HandlePlanCommandAsync("resume"); + return CliCommandResult.Continue; + } + + if (command == "plan-discard") + { + await HandlePlanCommandAsync("discard"); + return CliCommandResult.Continue; + } + + if (command == "plan" || command.StartsWith("plan ")) + { + _messages.Add(new ChatMsg { Role = "user", Text = input }); + // GetCommandName lower-cases for dispatch. Recover arguments from the original + // input so paths, identifiers, and quoted goal text keep their casing. + await HandlePlanCommandAsync(parsed.Arguments); + return CliCommandResult.Continue; + } + + if (command is "history" or "agent-history") + { + await ShowHistoryAsync(); + return CliCommandResult.Continue; + } + + if (command == "keybindings") + { + CliReferenceCommands.ShowKeybindings(); + return CliCommandResult.Continue; + } + if (command == "help") + { + // Append separate entries to the selected pane so the complete reference + // remains scrollable even when it is taller than the terminal viewport. + using var helpOutput = Pane is null ? null : TuiConsole.Enter(Pane.Session); + CliReferenceCommands.ShowHelp(Config); + return CliCommandResult.Continue; + } + if (command == "config" || command.StartsWith("config ")) + { + // "/config set " β€” inline setter, no wizard round-trip. + // Args are parsed from the RAW input: GetCommandName lowercases the + // command string, which would mangle case-sensitive values. + var rawConfigArgs = parsed.Arguments; + + if (rawConfigArgs.StartsWith("set", StringComparison.OrdinalIgnoreCase)) + { + await HandleConfigSetCommandAsync(rawConfigArgs[3..].Trim()); + return CliCommandResult.Continue; + } + if (rawConfigArgs.Length > 0) + { + TuiConsole.MarkupLine("[yellow]Unknown /config subcommand.[/]"); + TuiConsole.MarkupLine("[dim]Usage: [deepskyblue1]/config[/] (guided wizard) or [deepskyblue1]/config set [/][/]"); + TuiConsole.WriteLine(); + return CliCommandResult.Continue; + } + + await HandleConfigCommandAsync(); + return CliCommandResult.Continue; + } + + if (command == "transcript-save" || command.StartsWith("transcript-save ", StringComparison.Ordinal)) + { + var args = parsed.Arguments; + await CliReferenceCommands.SaveTranscriptAsync(Pane?.Session ?? TuiConsole.Current, + Pane?.Name ?? "MandoCode", ProjectRoot.ProjectRoot, Config.GetEffectiveModelName(), args); + return CliCommandResult.Continue; + } + if (command is "context-snap-create" or "context-snap-import") { await CaptureSnapshot(); await ShowSnapshots(command == "context-snap-create"); return CliCommandResult.Continue; } + if (command == "agent-settings") { _isProcessing = false; await ToggleSettings(); return CliCommandResult.Continue; } + if (command == "agent-file-explorer") + { + _fileExplorerOpen = !_fileExplorerOpen; + _gitChangesOpen = false; + await InvokeAsync(StateHasChanged); + return CliCommandResult.Continue; + } + if (command is "git-changes" or "agent-git-changes") + { + await ToggleGitChanges(); + return CliCommandResult.Continue; + } + if (command == "change-directory" || command.StartsWith("change-directory ")) + { + var rawArgs = parsed.Arguments; + string? target; + try { target = rawArgs.Length > 0 ? rawArgs : await PickProjectDirectoryAsync(); } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or System.Runtime.InteropServices.COMException or InvalidOperationException or System.IO.IOException) + { + TuiConsole.WriteLine($"Couldn't open the folder picker: {ex.Message}"); + return CliCommandResult.Continue; + } + if (!string.IsNullOrWhiteSpace(target)) + { + try + { + var directory = Shell.ChangeDirectory(target); + Skills.Reload(); + await AI.RefreshSettingsAsync(Config); + AI.AppendUserNote( + $"[Project root changed to: {directory}. Earlier messages refer to the previous folder β€” " + + "re-read any file you need rather than reusing paths or contents from before this point.]"); + TuiConsole.WriteLine($"Changed directory to {directory}"); + TuiConsole.WriteLine("Switching to the new project β€” this conversation is kept."); + } + catch (Exception ex) when (ex is System.IO.IOException or ArgumentException or UnauthorizedAccessException) + { + TuiConsole.WriteLine($"Couldn't change directory: {ex.Message}"); + } + await InvokeAsync(StateHasChanged); + } + return CliCommandResult.Continue; + } + if (command == "ollama-pull") { await ShowOllamaPull(); return CliCommandResult.Continue; } + if (command == "model") + { + await HandleModelCommandAsync(); + return CliCommandResult.Continue; + } + + if (command == "setup") + { + // Hide the HomeView component (static info + ready + help table) + // while the wizard runs imperatively β€” without this the VDOM + // redraws that block on top of the wizard's output every time + // SetOnboardingStatus fires StateHasChanged. forceInteractive + // ensures the wizard always shows visible UI rather than hitting + // the silent fast path, so users don't end up with a blank screen. + _setupActive = true; + await InvokeAsync(StateHasChanged); + + try + { + await RunOnboardingFlowAsync(forceInteractive: true); + } + finally + { + _setupActive = false; + } + + // Re-probe after the flow so /setup can also recover the connection state. + var probe = await OllamaSetupHelper.ProbeAsync(Config.OllamaEndpoint); + _isConnected = probe.Ok; + if (_isConnected) + { + await ValidateCurrentModelAsync(); + } + await InvokeAsync(StateHasChanged); + return CliCommandResult.Continue; + } + + if (command == "learn") + { + await HandleLearnCommandAsync(); + return CliCommandResult.Continue; + } + + if (command == "retry") + { + await HandleRetryCommandAsync(); + return CliCommandResult.Continue; + } + if (command == "ollama-serve") + { + await HandleRetryCommandAsync(); + return CliCommandResult.Continue; + } + + if (command == "copy") + { + HandleCopyCommand(); + return CliCommandResult.Continue; + } + + if (command == "copy-code") + { + HandleCopyCodeCommand(); + return CliCommandResult.Continue; + } + + if (command == "rabbit") + { + HandleRabbitCommand(); + return CliCommandResult.Continue; + } + + // Music commands + if (command == "music") { HandleMusicCommand("play"); return CliCommandResult.Continue; } + if (command == "music-stop") { HandleMusicCommand("stop"); return CliCommandResult.Continue; } + if (command == "music-pause") { HandleMusicCommand("pause"); return CliCommandResult.Continue; } + if (command == "music-next") { HandleMusicCommand("next"); return CliCommandResult.Continue; } + if (command == "music-playlist") { HandleMusicPlaySelection(); return CliCommandResult.Continue; } + if (command == "music-list") { HandleMusicCommand("list"); return CliCommandResult.Continue; } + if (command.StartsWith("music-vol")) + { + HandleMusicCommand(command); + return CliCommandResult.Continue; + } + + if (command == "command" || command.StartsWith("command ")) + { + var shellCmd = command.Length > 7 ? command[8..].Trim() : ""; + await Shell.HandleShellCommandAsync(shellCmd); + return CliCommandResult.Continue; + } + + if (command == "skills") + { + await ShowIntegrationsAsync(1); + return CliCommandResult.Continue; + } + + if (command == "force-skill" || command.StartsWith("force-skill ")) + { + var skillName = command.Length > "force-skill".Length + ? command["force-skill".Length..].Trim() + : ""; + await HandleForceSkillCommandAsync(skillName); + return CliCommandResult.Continue; + } + + if (command == "mcp") + { + await ShowIntegrationsAsync(0); + return CliCommandResult.Continue; + } + + if (command == "mcp tools" || command.StartsWith("mcp tools ")) + { + var serverFilter = command.Length > "mcp tools".Length + ? command["mcp tools".Length..].Trim() + : null; + await HandleMcpToolsCommandAsync(string.IsNullOrWhiteSpace(serverFilter) ? null : serverFilter); + return CliCommandResult.Continue; + } + + if (command == "mcp-reload") + { + await HandleMcpReloadCommandAsync(); + return CliCommandResult.Continue; + } + + // Unknown command + TuiConsole.MarkupLine($"[red]Unknown command: {Spectre.Console.Markup.Escape(input)}[/]"); + TuiConsole.MarkupLine("[dim]Type /help for available commands[/]"); + TuiConsole.WriteLine(); + return CliCommandResult.Continue; + } +} diff --git a/src/MandoCode/Components/App.Integrations.cs b/src/MandoCode/Components/App.Integrations.cs new file mode 100644 index 0000000..024a1e3 --- /dev/null +++ b/src/MandoCode/Components/App.Integrations.cs @@ -0,0 +1,197 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Components.Web; +using MandoCode.Models; +using MandoCode.Services; +using Spectre.Console; +using System.Text.RegularExpressions; + +namespace MandoCode.Components; + +public partial class App +{ + private string? PromptForSkill() + { + var skills = Skills.GetAll(); + if (skills.Count == 0) + { + TuiConsole.MarkupLine("[yellow]No skills installed.[/] Run [deepskyblue1]/skills[/] [yellow]for setup instructions.[/]"); + TuiConsole.WriteLine(); + return null; + } + + const string CancelChoice = "(cancel)"; + const string Separator = " β€” "; + + var entries = new List { CancelChoice }; + foreach (var s in skills) + { + entries.Add(string.IsNullOrWhiteSpace(s.Description) + ? s.Name + : s.Name + Separator + s.Description); + } + + string selected; + try + { + selected = TuiConsole.Prompt( + new SelectionPrompt() + .Title("[deepskyblue1]Select a skill to force:[/] [dim](type to filter)[/]") + .HighlightStyle(SelectionHighlight) + .PageSize(10) + .MoreChoicesText("[dim](scroll for more)[/]") + .EnableSearch() + .AddChoices(entries) + ); + } + catch (Exception) + { + TuiConsole.WriteLine(); + return null; + } + + if (selected == CancelChoice) + { + TuiConsole.WriteLine(); + return null; + } + + var dashIdx = selected.IndexOf(Separator, StringComparison.Ordinal); + return dashIdx > 0 ? selected[..dashIdx] : selected; + } + + private async Task HandleForceSkillCommandAsync(string skillName) + { + TuiConsole.WriteLine(); + + if (string.IsNullOrWhiteSpace(skillName)) + { + var picked = PromptForSkill(); + if (picked == null) return; + skillName = picked; + } + + var skill = Skills.GetByName(skillName); + if (skill == null) + { + TuiConsole.MarkupLine($"[red]No skill named[/] [deepskyblue1]{Spectre.Console.Markup.Escape(skillName)}[/] [red]is installed.[/]"); + TuiConsole.MarkupLine("[dim]Run[/] [deepskyblue1]/skills[/] [dim]to see available skills.[/]"); + TuiConsole.WriteLine(); + return; + } + + if (!_isConnected || _modelError) + { + TuiConsole.MarkupLine("[yellow]Cannot run skill β€” no model is connected.[/]"); + TuiConsole.WriteLine(); + return; + } + + TuiConsole.MarkupLine($"[green]βœ“ Forcing skill:[/] [deepskyblue1]{Spectre.Console.Markup.Escape(skill.Name)}[/]"); + if (!string.IsNullOrWhiteSpace(skill.Description)) + { + TuiConsole.MarkupLine($" [dim]{Spectre.Console.Markup.Escape(skill.Description)}[/]"); + } + TuiConsole.WriteLine(); + + var hostInstruction = + $"The user explicitly selected skill '{skill.Name}' via /force-skill. " + + $"Follow these instructions for this turn.\n\n# Skill: {skill.Name}\n\n{skill.Body}"; + + _messages.Add(new ChatMsg { Role = "user", Text = $"/force-skill {skill.Name}" }); + await ProcessDirectRequestAsync($"/force-skill {skill.Name}", hostInstruction); + } + + private async Task HandleMcpReloadCommandAsync() + { + var agents = (Pane?.Workspace.Registry?.Workspaces.SelectMany(w => w.Panes) ?? Pane?.Workspace.Panes ?? Array.Empty()).ToArray(); + if (agents.Any(agent => agent != Pane && agent.IsBusy?.Invoke() == true)) { TuiConsole.MarkupLine("[yellow]Finish work in other agents before reloading shared integrations.[/]"); return; } + var coordinator = IntegrationCoordinator; + using var update = coordinator.BeginUpdate(); + TuiConsole.WriteLine(); + TuiConsole.MarkupLine("[dim]Reloading MCP servers...[/]"); + + await coordinator.ReloadMcpAsync(agents); + + var active = McpManager.ActiveClients.Count; + var failed = McpManager.StartupErrors.Count; + TuiConsole.MarkupLine(failed == 0 + ? $"[green]MCP reloaded: {active} server(s) connected.[/]" + : $"[yellow]MCP reloaded: {active} connected, {failed} failed.[/]"); + TuiConsole.WriteLine(); + } + + /// + /// Lists tools exposed by one or all connected MCP servers, with descriptions. + /// If is null, iterates every active server. + /// + private async Task HandleMcpToolsCommandAsync(string? serverFilter) + { + TuiConsole.WriteLine(); + + if (McpManager.ActiveClients.Count == 0) + { + TuiConsole.MarkupLine("[yellow]No MCP servers are connected.[/]"); + TuiConsole.MarkupLine("[dim]Type /mcp to configure servers.[/]"); + TuiConsole.WriteLine(); + return; + } + + if (serverFilter != null && !McpManager.ActiveClients.ContainsKey(serverFilter)) + { + TuiConsole.MarkupLine($"[yellow]Server '{Spectre.Console.Markup.Escape(serverFilter)}' is not connected.[/]"); + TuiConsole.MarkupLine($"[dim]Connected: {string.Join(", ", McpManager.ActiveClients.Keys)}[/]"); + TuiConsole.WriteLine(); + return; + } + + var clients = serverFilter != null + ? new[] { (serverFilter, McpManager.ActiveClients[serverFilter]) } + : McpManager.ActiveClients.Select(kv => (kv.Key, kv.Value)).ToArray(); + + foreach (var (serverName, client) in clients) + { + IList tools; + try + { + tools = await client.ListToolsAsync(); + } + catch (Exception ex) + { + TuiConsole.MarkupLine($"[red]{Spectre.Console.Markup.Escape(serverName)}:[/] [dim]{Spectre.Console.Markup.Escape(ex.Message)}[/]"); + continue; + } + + var table = new Table().Border(TableBorder.Rounded).Title($"[deepskyblue1]{Spectre.Console.Markup.Escape(serverName)}[/] [dim]({tools.Count} tool{(tools.Count == 1 ? "" : "s")})[/]"); + table.AddColumn("Tool"); + table.AddColumn("Description"); + foreach (var t in tools) + { + var desc = string.IsNullOrWhiteSpace(t.Description) ? "[dim](no description)[/]" : Spectre.Console.Markup.Escape(t.Description); + table.AddRow(Spectre.Console.Markup.Escape(t.Name), desc); + } + TuiConsole.Write(table); + } + TuiConsole.WriteLine(); + } + + private async Task HandleLearnCommandAsync() + { + TuiConsole.WriteLine(); + LearnContent.Display(); + + if (_isConnected) + { + var wantChat = await WizardConfirmAsync("Chat with an AI educator to learn more?", false); + if (wantChat) + { + await AI.EnterLearnModeAsync(); + TuiConsole.WriteLine(); + TuiConsole.MarkupLine("[green]AI Educator mode active![/]"); + TuiConsole.MarkupLine("[dim]Ask me anything about local AI! Type /clear to return to normal mode.[/]"); + TuiConsole.WriteLine(); + } + } + } + +} diff --git a/src/MandoCode/Components/App.Lifecycle.cs b/src/MandoCode/Components/App.Lifecycle.cs new file mode 100644 index 0000000..f9e12e2 --- /dev/null +++ b/src/MandoCode/Components/App.Lifecycle.cs @@ -0,0 +1,291 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using MandoCode.Models; +using MandoCode.Services; +using Spectre.Console; + +namespace MandoCode.Components; + +public partial class App +{ + private readonly CallbackRegistrations _paneCallbacks = new(); + private void StopPane() + { + _paneStopped = true; + _requestCts?.Cancel(); + _snapshotCompletion?.TrySetResult(); + _setupCompletion?.TrySetResult(new(false, true, null)); + _historyTcs?.TrySetResult(); + _integrationsCompletion?.TrySetResult(); + _ollamaPullCompletion?.TrySetResult(); + _directorySelection?.TrySetResult(null); + _modelPickerTcs?.TrySetResult(null); + _wizardInputTcs?.TrySetResult(string.Empty); + _wizardSelectTcs?.TrySetResult(string.Empty); + _peerRequest?.Completion.TrySetResult(new(false, "Agent closed.")); + _runningPeerRequest?.Completion.TrySetResult(new(false, "Agent closed.")); + if (Pane?.AskPeer == AskPeerAsync) + { + // Stop accepting new peer work immediately; disposal releases the other + // callbacks only when they are still owned by this component. + Pane.AskPeer = null; + } + + _inputTcs?.TrySetResult(string.Empty); + _planSelectTcs?.TrySetResult(CancelRequestLabel); + } + + protected override void OnInitialized() + { + ResolveAgentServices(); + RegisterPaneCallbacks(); + using var output = Pane is null ? null : TuiConsole.Enter(Pane.Session); + // Get project root from command line args if available. Flags (--continue/-c, and + // anything future starting with '-') are not folder names β€” same filtering as + // Program.cs, or `mandocode --continue` would set the project root to "--continue". + var args = Environment.GetCommandLineArgs().Skip(1).ToArray(); + _continueRequested = (Pane is null || Pane.Id == 1) && args.Any(a => a is "--continue" or "-c"); + var positional = args.Where(a => !a.StartsWith('-')).ToArray(); + if (Pane?.RestoreFrom is null && positional.Length > 0 && !positional[0].StartsWith("config")) + { + ProjectRoot.ProjectRoot = positional[0]; + } + + // Subscribe to function invocation events for UI feedback. + // Store delegates so remounting an agent component can detach its subscriptions + // without retaining closures that capture request state. + _onFunctionInvokedHandler = OnFunctionInvoked; + _onFunctionCompletedHandler = OnFunctionCompleted; + AI.OnFunctionInvoked += _onFunctionInvokedHandler; + AI.OnFunctionCompleted += _onFunctionCompletedHandler; + _onResponseTextDeltaHandler = OnResponseTextDelta; + _onResponseStreamStartedHandler = OnResponseStreamStarted; + AI.OnResponseTextDelta += _onResponseTextDeltaHandler; + AI.OnResponseStreamStarted += _onResponseStreamStartedHandler; + // Wire the propose_plan tool handoff to the approval + execution UI + PlanHandoff.OnPlanRequested = HandleProposedPlanAsync; + // Re-render when DiffApprovalHandler activates the instruction TextInput. + InstructionCoordinator.StateChanged += OnInstructionStateChanged; + // Re-render when DiffApprovalHandler activates the VDOM approval menu. + ApprovalMenu.StateChanged += OnApprovalMenuStateChanged; + // Wire up diff approval callbacks if enabled + if (Config.EnableDiffApprovals) + { + AI.OnWriteApprovalRequested = DiffApproval.HandleDiffApproval; + AI.OnDeleteApprovalRequested = DiffApproval.HandleDeleteApproval; + AI.OnCommandApprovalRequested = DiffApproval.HandleCommandApproval; + McpGate.OnApprovalRequested = DiffApproval.HandleMcpApproval; + } + + // Detect terminal theme and apply curated palette + if (Config.EnableThemeCustomization && (Pane is null || Pane.Id == 1)) + { + ThemeService.DetectTheme(); + ThemeService.ApplyPalette(); + } + + // Ctrl+C cancels the active AI request instead of killing the process. + // Stored as a field so Dispose can detach it β€” the lambda closes over _requestCts, + // which would otherwise keep being dereferenced after teardown. + _cancelKeyHandler = (_, e) => + { + if (Pane is not null && !Pane.Active) + return; + var cts = _requestCts; + if (cts != null && !cts.IsCancellationRequested) + { + e.Cancel = true; // suppress default process termination + cts.Cancel(); + } + else + { + // No active request β€” clean up palette before process exits + ThemeService.ResetPalette(); + } + }; + Console.CancelKeyPress += _cancelKeyHandler; + } + + /// Resolve shared workspace services separately from the agent's scoped services. + /// Using the host scope for agent state would make panes share conversations and settings. + private void ResolveAgentServices() + { + IntegrationCoordinator = Services.GetRequiredService(); + Delegations = Services.GetRequiredService(); + ModelDefaults = Services.GetRequiredService(); + var agentServices = Pane?.Services ?? Services; + AI = agentServices.GetRequiredService(); + AI.UseExplicitPlanningOnly(); + Config = agentServices.GetRequiredService(); + PlanRunners = agentServices.GetRequiredService(); + PlanHandoff = agentServices.GetRequiredService(); + FileProvider = agentServices.GetRequiredService(); + TokenTracker = agentServices.GetRequiredService(); + MusicPlayer = agentServices.GetRequiredService(); + ProjectRoot = agentServices.GetRequiredService(); + Spinner = agentServices.GetRequiredService(); + OperationRenderer = agentServices.GetRequiredService(); + DiffApproval = agentServices.GetRequiredService(); + KeyCoordinator = agentServices.GetRequiredService(); + InstructionCoordinator = agentServices.GetRequiredService(); + ApprovalMenu = agentServices.GetRequiredService(); + Shell = agentServices.GetRequiredService(); + ThemeService = agentServices.GetRequiredService(); + StateMachine = agentServices.GetRequiredService(); + Skills = agentServices.GetRequiredService(); + McpManager = agentServices.GetRequiredService(); + McpGate = agentServices.GetRequiredService(); + PromptGate = agentServices.GetRequiredService(); + UpdateCheck = agentServices.GetRequiredService(); + } + + /// Register the component's pane callbacks as owned leases. An old component + /// must not clear callbacks installed by a replacement during remounting. + private void RegisterPaneCallbacks() + { + if (Pane is null) return; + + // History and request ownership belong to this mounted component. + _paneCallbacks.Bind>( + () => Pane.SaveHistory, value => Pane.SaveHistory = value, SaveAgentHistory); + _paneCallbacks.Bind>( + () => Pane.IsHistoryOpen, value => Pane.IsHistoryOpen = value, () => _historyActive); + _paneCallbacks.Bind( + () => Pane.CloseHistory, value => Pane.CloseHistory = value, CloseHistory); + _paneCallbacks.Bind>( + () => Pane.IsBusy, value => Pane.IsBusy = value, IsAgentBusy); + _paneCallbacks.Bind>( + () => Pane.IsAwaitingInput, value => Pane.IsAwaitingInput = value, () => HasModalInput); + _paneCallbacks.Bind( + () => Pane.Stop, value => Pane.Stop = value, StopPane); + _paneCallbacks.Bind>( + () => Pane.SubmitCommand, value => Pane.SubmitCommand = value, HandleVdomSubmit); + + // Other agents read a snapshot of the transcript on the renderer dispatcher. + _paneCallbacks.Bind>>( + () => Pane.AskPeer, value => Pane.AskPeer = value, AskPeerAsync); + _paneCallbacks.Bind>>>( + () => Pane.ReadConversation, value => Pane.ReadConversation = value, ReadPaneConversationAsync); + AI.SetHostTools(new CliAgentTools(Pane, Delegations).Functions); + + // Workspace shortcuts use callbacks so they do not depend on component internals. + _paneCallbacks.Bind>( + () => Pane.ToggleSnapshots, value => Pane.ToggleSnapshots = value, ToggleSnapshots); + _paneCallbacks.Bind>( + () => Pane.ToggleSettings, value => Pane.ToggleSettings = value, ToggleSettings); + _paneCallbacks.Bind>( + () => Pane.ToggleFileExplorer, value => Pane.ToggleFileExplorer = value, ToggleFileExplorer); + _paneCallbacks.Bind>( + () => Pane.IsFileExplorerOpen, value => Pane.IsFileExplorerOpen = value, () => _fileExplorerOpen); + _paneCallbacks.Bind>( + () => Pane.ToggleGitChanges, value => Pane.ToggleGitChanges = value, ToggleGitChanges); + _paneCallbacks.Bind>( + () => Pane.IsGitChangesOpen, value => Pane.IsGitChangesOpen = value, () => _gitChangesOpen); + } + + private bool IsAgentBusy() => !_hasRendered || _isProcessing + || InstructionCoordinator.IsActive || ApprovalMenu.IsActive + || _planSelectActive || _presentation.BlocksRequests; + + private async Task> ReadPaneConversationAsync() + { + IReadOnlyList conversation = []; + await InvokeAsync(() => conversation = _messages + .Select(message => new ChatMsg { Role = message.Role, Text = message.Text }) + .ToArray()); + return conversation; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + using var output = Pane is null ? null : TuiConsole.Enter(Pane.Session); + if (Pane is not null && _headerModel != Config.GetEffectiveModelName()) + { + _headerModel = Config.GetEffectiveModelName(); + Pane.Workspace.Refresh(); + } + + if (firstRender && !_connectionChecked) + { + await InitializeConnectionAsync(); + } + + if (_connectionChecked && !_hasRendered) + { + _hasRendered = true; + RestoreAgentHistory(); + // --continue: reload the most recent conversation for this project folder, + // full fidelity (tool calls included). Failure degrades to a fresh start with + // an honest note β€” never an error. + if (_continueRequested) + { + var savedJson = SessionResumeStore.Load(ProjectRoot.ProjectRoot); + var restoredCount = savedJson == null ? 0 : AI.TryRestoreHistoryJson(savedJson); + TuiConsole.MarkupLine(restoredCount > 0 ? $"[green]Conversation restored ({restoredCount} messages) β€” continuing where you left off.[/]" : "[dim]No previous session to continue for this folder β€” starting fresh.[/]"); + TuiConsole.WriteLine(); + // Same gesture, so the same place: "continue where I left off" should mention a + // plan that was still running as well as the conversation. + ShowUnfinishedPlanNotice(); + } + + // Initialize imperative input with the shared state machine + // Component input owns the keyboard; the imperative adapter is compatibility-only. + // Small delay to ensure UI is fully rendered before accepting input + await Task.Delay(100); + _ = Task.Run(async () => await RunInteractiveLoopAsync()); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + SaveAgentHistory(true); + _directorySelection?.TrySetResult(null); + using var output = Pane is null ? null : TuiConsole.Enter(Pane.Session); + StopPane(); + _paneCallbacks.Dispose(); + StopCancelKeyListener(); + StopMusicVisualizer(); + Spinner.Stop(); + // Detach event handlers registered in OnInitialized. + // AIService belongs to the agent scope; a component may remount within that scope. + // Without unsubscription, stale handlers linger + // across App lifecycle and capture a disposed _requestCts via closure. + if (_onFunctionInvokedHandler != null) + AI.OnFunctionInvoked -= _onFunctionInvokedHandler; + if (_onFunctionCompletedHandler != null) + AI.OnFunctionCompleted -= _onFunctionCompletedHandler; + if (_onResponseTextDeltaHandler != null) + AI.OnResponseTextDelta -= _onResponseTextDeltaHandler; + if (_onResponseStreamStartedHandler != null) + AI.OnResponseStreamStarted -= _onResponseStreamStartedHandler; + if (_cancelKeyHandler != null) + Console.CancelKeyPress -= _cancelKeyHandler; + InstructionCoordinator.StateChanged -= OnInstructionStateChanged; + ApprovalMenu.StateChanged -= OnApprovalMenuStateChanged; + // Clear the PlanHandoff callback so late-arriving propose_plan calls don't + // hit a disposed App. AIService stays, but plan requests just return the + // "not wired up" fallback from PlanHandoff.ProcessAsync. + if (PlanHandoff.OnPlanRequested == HandleProposedPlanAsync) + PlanHandoff.OnPlanRequested = null; + var cts = Interlocked.Exchange(ref _requestCts, null); + cts?.Dispose(); + MusicPlayer?.Dispose(); + if (Pane is null || Pane.Id == 1) + ThemeService?.Dispose(); + } + + public async ValueTask DisposeAsync() + { + if (Pane is null) + { + ((IDisposable)this).Dispose(); + return; + } + + Dispose(); + await Pane.DisposeAsync(); + } +} \ No newline at end of file diff --git a/src/MandoCode/Components/App.Planning.cs b/src/MandoCode/Components/App.Planning.cs new file mode 100644 index 0000000..d7dd02a --- /dev/null +++ b/src/MandoCode/Components/App.Planning.cs @@ -0,0 +1,940 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Components.Web; +using MandoCode.Models; +using MandoCode.Services; +using Spectre.Console; +using System.Text.RegularExpressions; + +namespace MandoCode.Components; + +public partial class App +{ + private const string ExecutePlanLabel = "Execute plan"; + private const string EditPlanStepLabel = "Edit a step"; + private const string RejectPlanLabel = "One-shot it"; + private const string CancelRequestLabel = "Cancel request"; + + // App-standard selection treatment for the remaining Spectre prompts β€” same + // black-on-deepskyblue1 as ApprovalSelect, DiffApprovalHandler, and the command + // autocomplete, so selection reads the same everywhere. + private static readonly Style SelectionHighlight = new(foreground: Color.Black, background: Color.DeepSkyBlue1); + + // Plan-approval select bridge β€” mirrors the wizard TCS pattern above. + private bool _planSelectActive; + private TaskCompletionSource? _planSelectTcs; + private static readonly ApprovalSelect.Option[] ProposedPlanOptions = + { + new(ExecutePlanLabel, Color.Green), + new(EditPlanStepLabel, Color.DeepSkyBlue1), + new(RejectPlanLabel, new Color(255, 200, 80)), + new(CancelRequestLabel, new Color(255, 200, 80)), + }; + + /// + /// Shows the VDOM plan-approval select and awaits the user's choice. Observes + /// so a cancelled request (Esc, timeout) unwinds the await + /// instead of leaving the turn stuck at an orphaned prompt. + /// + private IReadOnlyList _planSelectOptions = ProposedPlanOptions; + + private Task PromptPlanChoiceAsync(CancellationToken ct) => + PromptPlanChoiceAsync("The assistant proposes this plan. What would you like to do?", ProposedPlanOptions, ct); + + private async Task PromptPlanChoiceAsync(string title, IReadOnlyList options, CancellationToken ct) + { + _planSelectOptions = options; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _planSelectTcs = tcs; + using var cancelReg = ct.Register(() => tcs.TrySetCanceled(ct)); + + // Title goes to scrollback imperatively (same as WizardPromptSelectAsync) so the + // VDOM region holds only the option rows β€” keeps the live-repaint area minimal. + TuiConsole.MarkupLine($"[deepskyblue1]{Spectre.Console.Markup.Escape(title)}[/]"); + + _planSelectActive = true; + await InvokeAsync(StateHasChanged); + + string choice; + try + { + choice = await tcs.Task; + } + finally + { + _planSelectActive = false; + _planSelectTcs = null; + await InvokeAsync(StateHasChanged); + // Brief wait so the VDOM tears down the select before imperative writes + // (matches the wizard primitives' teardown delay). + await Task.Delay(50); + } + + // Persist the answer in scrollback (matches the > gold echo pattern in the main loop). + Console.WriteLine($"\u001b[38;2;255;200;80m> {choice}\u001b[0m"); + return choice; + } + + private void HandlePlanSelectSubmit(string choice) + { + _planSelectTcs?.TrySetResult(choice); + } + + /// + /// One line during --continue startup when this project has an unfinished plan. + /// + /// + /// + /// Shown only for --continue, because that is this app's existing "pick up where I left + /// off" gesture. Launching without it means a fresh session, and a plan from a previous one is + /// noise there. It also keeps the two halves of "where I left off" together: a resumed plan + /// runs with the conversation it came from rather than without it. + /// + /// + /// It notifies rather than resuming. Resuming writes files, and doing that automatically at + /// launch would let a plan the user had walked away from start changing their project before + /// they had read a single line. + /// + /// + private void ShowUnfinishedPlanNotice() + { + try + { + if (!PlanRunners.SupportsResume) return; + + var saved = PlanRunners.FindResumable(out _); + if (saved == null) return; + + var outstanding = PlanCheckpointStore.OutstandingSteps(saved); + if (outstanding == 0) return; + + var done = saved.Steps.Count - outstanding; + TuiConsole.WriteLine(); + TuiConsole.MarkupLine( + $"[yellow]Unfinished plan:[/] {Spectre.Console.Markup.Escape(saved.Goal)} " + + $"[dim]({done} of {saved.Steps.Count} steps done)[/]"); + TuiConsole.MarkupLine("[dim]/plan-resume[/] to continue, [dim]/plan-discard[/] to forget it."); + TuiConsole.WriteLine(); + } + catch { /* a notice must never stop the session starting */ } + } + + /// + /// `/plan` β€” inspect, resume or discard the plan recorded for this project. + /// + /// + /// Only the workflow engine records progress, so only it can be resumed; the legacy runner + /// keeps the plan in a local variable that dies with the process. The command says so rather + /// than reporting "nothing to resume", which would be indistinguishable from having lost work. + /// + private async Task HandlePlanCommandAsync(string arg) + { + TuiConsole.WriteLine(); + + if (!string.IsNullOrWhiteSpace(arg) && arg is not "resume" and not "discard") + { + await ForcePlanAsync(arg); + return; + } + + if (arg == "discard") + { + PlanRunners.DiscardResumable(); + TuiConsole.MarkupLine("[dim]Saved plan discarded.[/]"); + TuiConsole.WriteLine(); + return; + } + + var saved = PlanRunners.FindResumable(out var refusal); + + if (refusal != null) + { + // Explain rather than silently offering nothing β€” the plan is on disk, it just can't + // be safely continued as things stand. + TuiConsole.MarkupLine($"[yellow]{Spectre.Console.Markup.Escape(refusal)}[/]"); + TuiConsole.MarkupLine("[dim]Use[/] /plan-discard [dim]to forget it.[/]"); + TuiConsole.WriteLine(); + return; + } + + if (saved == null) + { + TuiConsole.MarkupLine("[dim]No unfinished plan for this project.[/]"); + TuiConsole.WriteLine(); + return; + } + + var outstanding = PlanCheckpointStore.OutstandingSteps(saved); + var done = saved.Steps.Count - outstanding; + + if (arg != "resume") + { + TuiConsole.MarkupLine( + $"[deepskyblue1]Unfinished plan:[/] {Spectre.Console.Markup.Escape(saved.Goal)}"); + TuiConsole.MarkupLine($"[dim]{done} of {saved.Steps.Count} steps done.[/]"); + TuiConsole.WriteLine(); + DisplaySavedPlan(saved); + TuiConsole.MarkupLine("[dim]/plan-resume[/] to continue, [dim]/plan-discard[/] to forget it."); + TuiConsole.WriteLine(); + return; + } + + TuiConsole.MarkupLine( + $"[green]Resuming:[/] {Spectre.Console.Markup.Escape(saved.Goal)} " + + $"[dim]({outstanding} step(s) left)[/]"); + TuiConsole.WriteLine(); + + // The plan was started in a process that no longer exists, so nothing has told the agent + // what request it is fulfilling. Steps treat that request as authoritative for WHERE work + // happens, so without it a resumed plan can write to the wrong place entirely. + AI.SetRequestContext(saved.Goal); + + // Steps already Completed or Skipped keep that status, so the runner steps over them and + // only outstanding work executes. ResumeAsync also seeds what earlier steps produced. + var plan = PlanCheckpointStore.ToPlan(saved); + + _requestCts = new CancellationTokenSource(); + StartCancelKeyListener(); + try + { + using var execution = PlanHandoff.BeginResumedExecution(saved.FileOperations); + var runner = PlanRunners.Current as WorkflowPlanRunner; + var progress = runner != null + ? runner.ResumeAsync(plan, saved, _requestCts.Token) + : PlanRunners.Current.ExecutePlanAsync(plan, _requestCts.Token); + + await foreach (var progressEvent in progress) + { + await HandleProgressEventAsync(progressEvent, plan, _requestCts.Token); + } + + if (PlanHandoff.TakeReplacementInstructions() is { } revisedInstructions && !_requestCts.IsCancellationRequested) + await ReviewReplacementPlanAsync(plan, revisedInstructions, _requestCts.Token); + var manifest = PlanHandoff.BuildManifest(plan, PlanHandoff.FileOperations); + AI.AppendAssistantNote(manifest); + } + catch (OperationCanceledException) + { + TuiConsole.MarkupLine("[yellow]Resume cancelled.[/]"); + } + finally + { + Spinner.Stop(); + StopCancelKeyListener(); + var oldCts = Interlocked.Exchange(ref _requestCts, null); + oldCts?.Dispose(); + } + + TuiConsole.WriteLine(); + } + + private Task ForcePlanAsync(string goal) => ForcePlanAsync(goal, goal); + + private async Task ForcePlanAsync(string planningRequest, string originalRequest) + { + _requestCts = new CancellationTokenSource(); + StartCancelKeyListener(); + var rejected = false; + try + { + AI.AppendUserNote(originalRequest); + Spinner.Start("Creating plan..."); + var proposal = await AI.GeneratePlanAsync(planningRequest, cancellationToken: _requestCts.Token); + Spinner.Stop(); + _lastPlanOutcome = PlanTurnOutcome.None; + var manifest = await PlanHandoff.ProcessAsync( + proposal.Goal, proposal.Steps, _requestCts.Token, originalRequest: originalRequest); + rejected = _lastPlanOutcome == PlanTurnOutcome.Rejected; + if (_lastPlanOutcome == PlanTurnOutcome.Executed && !string.IsNullOrWhiteSpace(manifest)) + AI.AppendAssistantNote(manifest); + } + catch (OperationCanceledException) + { + TuiConsole.MarkupLine("[yellow]Planning cancelled.[/]"); + } + catch (Exception ex) + { + TuiConsole.MarkupLine($"[red]Could not create plan:[/] {Spectre.Console.Markup.Escape(ex.Message)}"); + } + finally + { + Spinner.Stop(); + StopCancelKeyListener(); + var oldCts = Interlocked.Exchange(ref _requestCts, null); + oldCts?.Dispose(); + } + + if (rejected) + { + await ProcessDirectRequestAsync( + "Proceed with my original request directly.", + "The user reviewed the forced plan and chose to skip stepwise execution. " + + "Answer the request directly now. Do not call propose_plan."); + PlanHandoff.ClearPendingProposal(); + } + } + + private static void DisplaySavedPlan(PlanRunState saved) + { + var table = new Spectre.Console.Table { Border = TableBorder.Rounded }; + table.AddColumn("Step"); + table.AddColumn("Description"); + table.AddColumn("Status"); + + foreach (var step in saved.Steps) + { + var status = step.Status switch + { + TaskStepStatus.Completed => "[green]done[/]", + TaskStepStatus.Skipped => "[dim]skipped[/]", + TaskStepStatus.Failed => "[red]failed[/]", + _ => "[yellow]pending[/]", + }; + table.AddRow( + step.Number.ToString(), + Spectre.Console.Markup.Escape(step.Description), + status); + } + + TuiConsole.Write(table); + TuiConsole.WriteLine(); + } + + private enum PlanTurnOutcome { None, Executed, Rejected, Cancelled } + + private PlanTurnOutcome _lastPlanOutcome = PlanTurnOutcome.None; + + // Bounds the answer-directly follow-up to a single extra turn. Without it a model that responds + // to a rejection by proposing another plan could ping-pong indefinitely. + private int _planFollowUpDepth; + + /// + /// Runs a plan the model proposed during the turn that just finished, then records the outcome + /// in chat history. No-op when no plan was proposed. + /// + /// + /// An executed plan's manifest goes into history as an assistant note rather than being fed back + /// to the model for a closing turn. That is deliberate: giving the model an open turn after a + /// plan is exactly what used to make it redo the work, and no amount of "the files already + /// exist" phrasing reliably stopped it. + /// + /// A rejected plan is the one case that does need another turn β€” the user asked for a direct + /// answer instead of stepwise execution, and before plans were deferred the model got that for + /// free by simply continuing its open turn. + /// + /// + private async Task RunPendingPlanAsync() + { + if (!PlanHandoff.HasPendingProposal) return; + + // Already inside the post-rejection follow-up: the user has just declined a plan, so + // silently running another one would be the opposite of what they asked for. + if (_planFollowUpDepth > 0) + { + PlanHandoff.ClearPendingProposal(); + return; + } + + var ct = _requestCts?.Token ?? CancellationToken.None; + + // The user cancelled this turn; running the plan it proposed is the opposite of what they + // asked for, and leaving it queued would run it at the end of some later, unrelated turn. + if (ct.IsCancellationRequested) + { + PlanHandoff.ClearPendingProposal(); + return; + } + + _lastPlanOutcome = PlanTurnOutcome.None; + var manifest = await PlanHandoff.RunPendingPlanAsync(ct); + + if (_lastPlanOutcome == PlanTurnOutcome.Rejected) + { + _planFollowUpDepth++; + try + { + await ProcessDirectRequestAsync( + "Continue with my original request.", + "The user reviewed your proposed plan and chose to skip stepwise execution. " + + "Answer their original request directly now. Do not call propose_plan."); + } + finally + { + _planFollowUpDepth--; + } + return; + } + + if (string.IsNullOrWhiteSpace(manifest)) return; + + AI.AppendAssistantNote(manifest); + + // Keep --continue honest: the plan's outcome is part of this turn. + try { if (Pane is null || Pane.Id == 1) SessionResumeStore.Save(ProjectRoot.ProjectRoot, AI.ExportHistoryJson()); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { System.Diagnostics.Trace.TraceWarning("Could not save compacted context: {0}", ex.Message); } + SaveAgentHistory(false); + } + + private async Task HandleProposedPlanAsync(TaskPlan plan, CancellationToken ct) + { + string choice; + // Serialize the plan-approval prompt against the diff/command/MCP prompts β€” the + // model can emit propose_plan alongside another approval-gated tool call in the + // same response. Held ONLY around the prompt, never around plan execution: the + // plan's own step approvals acquire this same gate, so holding it across + // ExecutePlanAsync would deadlock the first step. + using (await PromptGate.AcquireAsync(ct)) + { + // Stop the outer "Thinking..." spinner so it doesn't fight the approval prompt. + Spinner.Stop(); + + // One suppression scope spans the approval menu AND every follow-up step picker/editor. + // Ending it after the first menu lets the background Escape listener race RazorConsole's + // keyboard pump and silently steal arrow presses and typed characters. + using (KeyCoordinator.Suppress()) + { + // Loops so editing a step returns to the menu: reviewing a plan is iterative, and + // being forced to approve or reject immediately after one edit defeats the point. + while (true) + { + TuiConsole.WriteLine(); + DisplayPlan(plan); + + // VDOM select instead of a Spectre SelectionPrompt β€” Spectre's blocking + // ReadKey races RazorConsole's keyboard pump and intermittently lost arrow + // presses (see ApprovalSelect.razor). + choice = await PromptPlanChoiceAsync(ct); + + if (choice != EditPlanStepLabel) break; + + await EditPlanStepAsync(plan, ct); + } + } + } + + if (choice == CancelRequestLabel) + { + _lastPlanOutcome = PlanTurnOutcome.Cancelled; + TuiConsole.MarkupLine("[dim]Plan cancelled.[/]"); + TuiConsole.WriteLine(); + // Cancel the token rather than trusting a "stop here" string. This predates deferred + // execution β€” observed live, the model received exactly that string and went on to + // execute the cancelled plan's steps itself via direct tool calls. The model has no + // open turn to do that in any more, but cancelling is still what unwinds the plan run + // itself (same path as Esc), so it stays. + _requestCts?.Cancel(); + return "User cancelled the request. Stop here and do not take further action."; + } + + if (choice == RejectPlanLabel) + { + _lastPlanOutcome = PlanTurnOutcome.Rejected; + TuiConsole.MarkupLine("[dim]Plan skipped β€” one-shotting your request.[/]"); + TuiConsole.WriteLine(); + // The caller turns this into a fresh follow-up turn: with execution deferred there is + // no open turn left for the model to answer in, so one is started explicitly. + return "User rejected the proposed plan."; + } + + _lastPlanOutcome = PlanTurnOutcome.Executed; + + TuiConsole.WriteLine(); + TuiConsole.MarkupLine("[green]Executing plan...[/]"); + TuiConsole.WriteLine(); + + try + { + await foreach (var progressEvent in PlanRunners.Current.ExecutePlanAsync(plan, ct)) + { + await HandleProgressEventAsync(progressEvent, plan, ct); + } + } + catch (OperationCanceledException) + { + Spinner.Stop(); + PlanRunners.Current.CancelPlan(plan); + } + + if (PlanHandoff.TakeReplacementInstructions() is { } revisedInstructions && !ct.IsCancellationRequested) + return await ReviewReplacementPlanAsync(plan, revisedInstructions, ct); + + TuiConsole.WriteLine(); + if (plan.Status == TaskPlanStatus.Completed) + { + TuiConsole.MarkupLine("[green]Plan completed successfully![/]"); + } + else if (plan.Status == TaskPlanStatus.CompletedWithIssues) + { + TuiConsole.MarkupLine("[yellow]Plan completed with skipped or failed steps.[/]"); + } + else if (plan.Status == TaskPlanStatus.Cancelled) + { + TuiConsole.MarkupLine("[yellow]Plan was cancelled.[/]"); + } + else if (plan.Status == TaskPlanStatus.Paused) + { + TuiConsole.MarkupLine("[yellow]Plan paused; outstanding work remains saved.[/]"); + } + else + { + TuiConsole.MarkupLine("[red]Plan completed with errors.[/]"); + } + + if (!string.IsNullOrEmpty(plan.ExecutionSummary)) + { + TuiConsole.MarkupLine($"[dim]{Spectre.Console.Markup.Escape(plan.ExecutionSummary)}[/]"); + } + TuiConsole.WriteLine(); + + // The summary alone is pure information β€” small models treat an open turn as an + // invitation to keep building (observed: a completed 5-step plan was followed by + // an uninvited duplicate project AND a third plan proposal). End with an explicit + // stop directive; the scope-level one-plan-per-turn circuit backs it up mechanically. + var summary = plan.ExecutionSummary + ?? $"Plan finished with status {plan.Status}."; + return summary + + "\n\nThe plan has finished. Respond to the user now with a brief summary of the outcome. " + + "Do NOT create more files, call more tools, or propose another plan."; + } + + private async Task ReviewReplacementPlanAsync(TaskPlan oldPlan, string instructions, CancellationToken ct) + { + TuiConsole.MarkupLine("[yellow]The previous plan was stopped because you changed the instructions. Review a replacement before work resumes.[/]"); + var updatedRequest = $"Original request:\n{oldPlan.OriginalRequest}\n\nLatest user instructions (supersede conflicting earlier requirements):\n{instructions}"; + AI.AppendUserNote(instructions); + AI.SetRequestContext(updatedRequest); + Spinner.Start("Creating replacement plan..."); + TaskPlan replacement; + try + { + var context = "The previous plan was cancelled. Generate new steps and acceptance criteria for the latest instructions. " + + "Inspect existing work and retain completed changes that still satisfy the updated request; do not execute tools or repeat work while planning.\n" + + "Earlier step results:\n" + string.Join("\n", oldPlan.Steps.Select(step => $"{step.Description}: {step.Result ?? step.ErrorMessage ?? step.Status.ToString()}")) + + "\nObserved file changes:\n" + string.Join("\n", PlanHandoff.FileOperations.Select(op => $"{op.Operation}: {op.Path}")); + var proposal = await AI.GeneratePlanAsync(updatedRequest, context, ct); + replacement = new TaskPlan { OriginalRequest = updatedRequest, Steps = TaskPlannerService.FromProposals(proposal.Steps), Status = TaskPlanStatus.Pending }; + if (replacement.Steps.Count == 0) throw new InvalidOperationException("The replacement plan contained no steps; the previous plan remains stopped."); + PlanFinalQuality.Ensure(replacement); + } + finally { Spinner.Stop(); } + var summary = await HandleProposedPlanAsync(replacement, ct); + // The outer handoff builds its manifest from the same plan object. Carry the + // reviewed replacement's outcome back so it never reports the stale criteria. + oldPlan.OriginalRequest = replacement.OriginalRequest; + oldPlan.Steps = replacement.Steps; + oldPlan.Status = replacement.Status; + oldPlan.ExecutionSummary = replacement.ExecutionSummary; + return summary; + } + + /// + /// Renders the proposed plan for approval, including each step's instruction. + /// + /// + /// The instruction is shown because it is the text actually sent to the model β€” the + /// description is a ≀60-character label for this table and nothing else. Showing only the + /// label meant approving work you had not read: two steps can look identical in summary and + /// target completely different files. + /// + private void DisplayPlan(TaskPlan plan) + { + var table = new Spectre.Console.Table() + .Border(TableBorder.Rounded) + .AddColumn(new TableColumn("Step").Centered()) + .AddColumn(new TableColumn("Description")) + .AddColumn(new TableColumn("What it will do")); + + foreach (var step in plan.Steps) + { + var instruction = step.Instruction ?? ""; + if (step.AcceptanceCriteria.Count > 0) instruction += "\n\n" + PlanAcceptance.Describe(step); + // Wrapped rather than truncated: a clipped instruction is the same problem as showing + // none β€” you cannot approve what you cannot read. + table.AddRow( + $"[deepskyblue1]{step.StepNumber}[/]", + Spectre.Console.Markup.Escape(step.Description), + $"[dim]{Spectre.Console.Markup.Escape(instruction)}[/]" + ); + } + + TuiConsole.MarkupLine("[deepskyblue1]Created plan:[/]"); + TuiConsole.WriteLine(); + TuiConsole.Write(table); + TuiConsole.WriteLine(); + } + + /// + /// Lets the user rewrite a step's instruction before the plan runs. + /// + /// + /// Edits the instruction, not the description: the description is a display label, so changing + /// it would alter what the table says without changing what actually happens β€” the worst of + /// both. The step's own text is the thing with consequences. + /// + private async Task EditPlanStepAsync( + TaskPlan plan, + CancellationToken ct, + int minimumStepNumber = 1, + bool reviseFollowingSteps = true) + { + // This method owns two consecutive VDOM inputs (row selector, then text editor). + // Keep the background Escape listener out even if a future caller forgets to suppress it. + using var keyboardOwnership = KeyCoordinator.Suppress(); + + TuiConsole.WriteLine(); + + const string goBackLabel = "Go back"; + var selectableSteps = plan.Steps + .Where(candidate => candidate.StepNumber >= minimumStepNumber) + .ToArray(); + if (selectableSteps.Length == 0) + { + TuiConsole.MarkupLine("[dim]No step selected.[/]"); + TuiConsole.WriteLine(); + return false; + } + + var stepLabels = selectableSteps + .Select(candidate => $"Step {candidate.StepNumber} β€” {candidate.Description}") + .Append(goBackLabel) + .ToArray(); + var stepOptions = stepLabels + .Select((label, index) => new ApprovalSelectCoordinator.Option( + label, + index < selectableSteps.Length ? Color.DeepSkyBlue1 : new Color(255, 200, 80))) + .ToArray(); + + // ApprovalSelect keeps a stable row shape while repainting both the highlight and cursor. + // RazorConsole's generic Select left its '>' marker behind on the first row even while the + // highlight moved. The custom component was written specifically to avoid that VDOM diff bug. + TuiConsole.MarkupLine("[deepskyblue1]Select a step to edit (Up/Down, Enter):[/]"); + var selectedLabel = await ApprovalMenu.RequestAsync(stepOptions); + + if (selectedLabel == goBackLabel) + { + TuiConsole.MarkupLine("[dim]No step selected.[/]"); + TuiConsole.WriteLine(); + return false; + } + + var selectedIndex = Array.IndexOf(stepLabels, selectedLabel); + if (selectedIndex < 0 || selectedIndex >= selectableSteps.Length) + return false; + + var step = selectableSteps[selectedIndex]; + + var revised = await InstructionCoordinator.RequestAsync( + $"Edit step {step.StepNumber} instruction", + step.Instruction, multiline: true); + + if (string.IsNullOrWhiteSpace(revised) || + string.Equals(revised.Trim(), step.Instruction?.Trim(), StringComparison.Ordinal)) + { + TuiConsole.MarkupLine("[dim]Left unchanged.[/]"); + TuiConsole.WriteLine(); + return false; + } + + var trimmed = revised.Trim(); + step.Instruction = trimmed; + step.AcceptanceCriteria = [trimmed]; + // A stale label makes the review table misleading, so always derive it from the edited text. + step.Description = trimmed.Length > 60 ? trimmed[..57] + "..." : trimmed; + + TuiConsole.MarkupLine($"[green]Step {step.StepNumber} updated.[/]"); + TuiConsole.WriteLine(); + + if (reviseFollowingSteps && plan.Steps.Any(candidate => candidate.StepNumber > step.StepNumber)) + await ReviseFollowingStepsAfterEditAsync(plan, step, ct); + + return true; + } + + private async Task ReviseFollowingStepsAfterEditAsync( + TaskPlan plan, + TaskStep editedStep, + CancellationToken ct) + { + TuiConsole.MarkupLine( + $"[dim]Updating steps after {editedStep.StepNumber} so they stay consistent with your edit...[/]"); + Spinner.Start("Updating dependent steps..."); + + try + { + var earlier = plan.Steps + .Where(step => step.StepNumber < editedStep.StepNumber) + .Select(step => $"Step {step.StepNumber}: {step.Description}\nInstruction: {step.Instruction}"); + var later = plan.Steps + .Where(step => step.StepNumber > editedStep.StepNumber) + .Select(step => $"Step {step.StepNumber}: {step.Description}\nInstruction: {step.Instruction}"); + var context = + $"The user edited step {editedStep.StepNumber}. Their edited instruction is authoritative and must not be changed:\n" + + $"{editedStep.Instruction}\n\n" + + "Earlier steps that must remain unchanged:\n" + string.Join("\n\n", earlier) + "\n\n" + + "Return only replacement steps that come after the edited step. Update paths, values, and verification " + + "expectations so they are consistent with the edit. State each replacement as the actual work to execute; " + + "never say to replace, update, or revise a plan step, and never refer to old step numbers. " + + "Do not repeat earlier or edited steps.\n\n" + + "Current later steps to replace:\n" + string.Join("\n\n", later); + + var revision = await AI.GeneratePlanAsync(plan.OriginalRequest, context, ct); + if (revision.Steps.Length == 1 && + revision.Steps[0].description == "Complete the requested goal" && + revision.Steps[0].instruction == plan.OriginalRequest.Trim()) + throw new InvalidOperationException("The model did not return a usable dependent-step revision."); + + var candidate = PlanRevision.CreateFollowingCandidate(plan, editedStep.StepNumber, revision); + PlanRevision.ApplyFollowing(plan, editedStep.StepNumber, candidate); + TuiConsole.MarkupLine( + "[green]Dependent steps updated.[/] [dim]Review the complete plan before executing it.[/]"); + TuiConsole.WriteLine(); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + TuiConsole.MarkupLine( + $"[yellow]Could not update dependent steps automatically:[/] {Spectre.Console.Markup.Escape(ex.Message)}"); + TuiConsole.MarkupLine("[dim]Review the later steps manually before execution.[/]"); + TuiConsole.WriteLine(); + } + finally + { + Spinner.Stop(); + } + } + + private async Task HandleProgressEventAsync( + TaskProgressEvent progressEvent, + TaskPlan plan, + CancellationToken ct) + { + switch (progressEvent.ProgressType) + { + case TaskProgressType.StepActivity: + Spinner.UpdateActivity(progressEvent.Message ?? "Checking step..."); + break; + + case TaskProgressType.PlanPaused: + Spinner.Stop(); + TuiConsole.MarkupLine($"[yellow]{Spectre.Console.Markup.Escape(progressEvent.Message ?? "Plan paused.")}[/]"); + TuiConsole.MarkupLine("[dim]Outstanding work is saved. Use /plan-resume to continue.[/]"); + break; + + case TaskProgressType.PersistenceWarning: + TuiConsole.MarkupLine($"[yellow]{Spectre.Console.Markup.Escape(progressEvent.Message ?? "Plan progress could not be saved.")}[/]"); + break; + + case TaskProgressType.StepStarted: + Spinner.Stop(); + _recentReadCount = 0; + _recentReadFiles.Clear(); + _lastOperationType = null; + SpinnerService.SetTaskbarProgress((progressEvent.CurrentStep - 1) * 100 / progressEvent.TotalSteps); + TuiConsole.MarkupLine($"[deepskyblue1]Step {progressEvent.CurrentStep}/{progressEvent.TotalSteps}:[/] {Spectre.Console.Markup.Escape(progressEvent.StepDescription)}"); + TuiConsole.WriteLine(); + Spinner.Start("Working..."); + break; + + case TaskProgressType.StepCompleted: + Spinner.Stop(); + SpinnerService.SetTaskbarProgress(progressEvent.CurrentStep * 100 / progressEvent.TotalSteps); + if (!string.IsNullOrEmpty(progressEvent.Message)) + { + // Render through the same guarded markdown pipeline as non-plan AI + // responses so **bold**, lists, inline code, and file-path linkification + // match β€” AND so a malformed/huge step message can't hang the plan loop. + // Without the guard, one bad step's render froze the whole multi-step run. + RenderMarkdownGuarded(progressEvent.Message); + } + TuiConsole.MarkupLine($"[green]Step {progressEvent.CurrentStep} completed.[/]"); + TuiConsole.WriteLine(); + break; + + case TaskProgressType.StepFailed: + Spinner.Stop(); + SpinnerService.SetTaskbarError(progressEvent.CurrentStep * 100 / progressEvent.TotalSteps); + TuiConsole.MarkupLine($"[red]Step {progressEvent.CurrentStep} failed:[/] {Spectre.Console.Markup.Escape(progressEvent.Message ?? "Unknown error")}"); + + // Cancellation is already the user's terminal decision. Diff/command approval + // cancellation reports a StepFailed event while also marking the plan cancelled; + // do not follow that with a contradictory retry/replan/skip prompt. + if (plan.Status == TaskPlanStatus.Cancelled || ct.IsCancellationRequested) + { + SpinnerService.ClearTaskbarProgress(); + break; + } + + // Ask user what to do + // Palette: green = try again, gold = redirect (skip), red = destructive. + const string retryStepLabel = "Retry this step"; + const string revisePlanLabel = "Revise the remaining plan"; + const string skipStepLabel = "Skip this step and continue"; + const string cancelPlanLabel = "Cancel the plan"; + + var stepToDecide = plan.Steps.FirstOrDefault(s => s.StepNumber == progressEvent.CurrentStep); + + // Retry is offered only on the workflow engine. The legacy runner walks the steps + // with a foreach and has already moved past this one β€” it has no way back. + var choices = new List(); + if (PlanRunners.UsingWorkflowEngine) + { + choices.Add(new(retryStepLabel, Color.Green)); + choices.Add(new(revisePlanLabel, Color.DeepSkyBlue1)); + } + choices.Add(new(skipStepLabel, new Color(255, 200, 80))); + choices.Add(new(cancelPlanLabel, Color.Red)); + + string failChoice; + using (await PromptGate.AcquireAsync(ct)) + using (KeyCoordinator.Suppress()) + { + failChoice = await PromptPlanChoiceAsync("How would you like to proceed?", choices, ct); + } + if (failChoice == cancelPlanLabel) + { + PlanRunners.Current.CancelPlan(plan); + } + else if (failChoice == retryStepLabel && stepToDecide != null) + { + // Pending is the signal triage reads as "run this one again". + stepToDecide.Status = TaskStepStatus.Pending; + TuiConsole.MarkupLine($"[dim]Retrying step {progressEvent.CurrentStep}...[/]"); + TuiConsole.WriteLine(); + } + else if (failChoice == revisePlanLabel && stepToDecide != null) + { + var decision = await ReplanAfterFailureAsync( + plan, + stepToDecide, + progressEvent.Message ?? "Unknown error", + ct); + if (decision == CliReplanDecision.Cancel) + PlanRunners.Current.CancelPlan(plan); + else if (decision == CliReplanDecision.KeepCurrent) + PlanRunners.Current.SkipStep(plan, stepToDecide); + } + else if (stepToDecide != null) + { + PlanRunners.Current.SkipStep(plan, stepToDecide); + SpinnerService.SetTaskbarWarning(progressEvent.CurrentStep * 100 / progressEvent.TotalSteps); + } + break; + + case TaskProgressType.PlanCompleted: + Spinner.Stop(); + SpinnerService.ClearTaskbarProgress(); + break; + + case TaskProgressType.PlanCancelled: + Spinner.Stop(); + SpinnerService.ClearTaskbarProgress(); + break; + } + } + + private enum CliReplanDecision { Applied, KeepCurrent, Cancel } + + private async Task ReplanAfterFailureAsync( + TaskPlan plan, + TaskStep failedStep, + string error, + CancellationToken ct) + { + Spinner.Start("Revising plan..."); + TuiConsole.MarkupLine("[dim]Revising the unfinished portion of the plan...[/]"); + + TaskPlan candidate; + try + { + var settled = plan.Steps + .Where(step => step.StepNumber < failedStep.StepNumber) + .Select(step => $"Step {step.StepNumber} [{step.Status}]: {step.Description}\nResult: {step.Result ?? "(none)"}"); + var remaining = plan.Steps + .Where(step => step.StepNumber >= failedStep.StepNumber) + .Select(step => $"Step {step.StepNumber}: {step.Description}\nInstruction: {step.Instruction}"); + var context = + $"Failed step {failedStep.StepNumber}: {failedStep.Description}\n" + + $"Failure: {error}\n\n" + + "Settled earlier steps:\n" + string.Join("\n\n", settled) + "\n\n" + + "Return only replacements for the failed and later steps. State each replacement as the actual work to " + + "execute; never say to replace, update, or revise a plan step, and never refer to old step numbers. " + + "Do not repeat settled steps.\n\n" + + "Current failed and remaining steps to replace:\n" + string.Join("\n\n", remaining); + + var revision = await AI.GeneratePlanAsync(plan.OriginalRequest, context, ct); + if (revision.Steps.Length == 1 && + revision.Steps[0].description == "Complete the requested goal" && + revision.Steps[0].instruction == plan.OriginalRequest.Trim()) + throw new InvalidOperationException("The model did not return a usable revision."); + + candidate = PlanRevision.CreateCandidate(plan, failedStep.StepNumber, revision); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + TuiConsole.MarkupLine($"[red]Could not revise the plan:[/] {Spectre.Console.Markup.Escape(ex.Message)}"); + return CliReplanDecision.KeepCurrent; + } + finally + { + Spinner.Stop(); + } + + const string useLabel = "Use revised plan"; + const string editLabel = "Edit a step"; + const string keepLabel = "Keep current plan and skip this step"; + const string cancelLabel = "Cancel the plan"; + string choice; + + // Keep the background Escape listener suppressed across the revised-plan menu and + // its nested row selector/text editor. Releasing ownership between those VDOM inputs + // makes the listener consume their keys before RazorConsole sees them. + using (KeyCoordinator.Suppress()) + { + while (true) + { + TuiConsole.WriteLine(); + DisplayPlan(candidate); + TuiConsole.MarkupLine( + $"[dim]Steps before {failedStep.StepNumber} are settled and will not run again.[/]"); + + using (await PromptGate.AcquireAsync(ct)) + { + choice = await PromptPlanChoiceAsync("Review the revised remaining plan", + new ApprovalSelect.Option[] + { + new(useLabel, Color.Green), + new(editLabel, Color.DeepSkyBlue1), + new(keepLabel, new Color(255, 200, 80)), + new(cancelLabel, Color.Red) + }, ct); + } + if (choice == editLabel) + { + await EditPlanStepAsync( + candidate, + ct, + failedStep.StepNumber, + reviseFollowingSteps: false); + continue; + } + if (choice == cancelLabel) return CliReplanDecision.Cancel; + if (choice == keepLabel) return CliReplanDecision.KeepCurrent; + + PlanRevision.ApplyApproved(plan, failedStep.StepNumber, candidate); + TuiConsole.MarkupLine( + $"[green]Revised plan approved[/] [dim]β€” resuming at step {failedStep.StepNumber} of {plan.Steps.Count}.[/]"); + TuiConsole.WriteLine(); + return CliReplanDecision.Applied; + } + } + } + + + /// + /// Shows a fuzzy-searchable picker of installed skills and returns the selected skill + /// name (or null if the user cancels or no skills are installed). Uses Spectre's + /// SelectionPrompt with EnableSearch so typing filters the list. + /// +} diff --git a/src/MandoCode/Components/App.Requests.cs b/src/MandoCode/Components/App.Requests.cs new file mode 100644 index 0000000..a9c259f --- /dev/null +++ b/src/MandoCode/Components/App.Requests.cs @@ -0,0 +1,318 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Components.Web; +using MandoCode.Models; +using MandoCode.Services; +using Spectre.Console; +using System.Text.RegularExpressions; + +namespace MandoCode.Components; + +public partial class App +{ + private async Task ProcessDirectRequestAsync(string input, string? hostInstruction = null, CancellationToken cancellationToken = default) + { + _lastRequestError = null; + _lastCompletedReply = null; + (Pane?.Session ?? TuiConsole.Current)?.BeginToolTurn(); + // A proposal belongs to the turn that produced it. Without this, a plan proposed during a + // turn the user then cancelled would sit in the single slot and execute at the end of some + // later, unrelated turn. + PlanHandoff.ClearPendingProposal(); + + // Reset operation tracking for the new request + _recentReadCount = 0; + _recentReadFiles.Clear(); + _lastOperationType = null; + + TuiConsole.WriteLine(); + + _requestCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var token = _requestCts.Token; + StartCancelKeyListener(); + + OpenReply(); + try + { + var receivedFirstChunk = false; + var stream = string.IsNullOrWhiteSpace(hostInstruction) + ? AI.ChatStreamAsync(input, token) + : AI.ChatStreamWithHostInstructionAsync(input, hostInstruction, token); + var enumerator = stream.GetAsyncEnumerator(token); + + try + { + // Show our controllable spinner while waiting for first chunk + // This spinner can be stopped by HandleDiffApproval when it needs user input + Spinner.Start("Thinking..."); + + if (await enumerator.MoveNextAsync()) + { + receivedFirstChunk = true; + Snapshots.ClearImports(); + } + + Spinner.Stop(); + + if (!receivedFirstChunk) + { + TuiConsole.MarkupLine("[yellow]No response from model. The request may have exceeded the model's context window.[/]"); + TuiConsole.MarkupLine("[dim]Try a smaller request, or switch to a model with a larger context window via /config.[/]"); + } + + // Buffer all chunks, then render as rich markdown + if (receivedFirstChunk) + { + var responseBuffer = new System.Text.StringBuilder(); + var lineBuffer = new System.Text.StringBuilder(); + var showSpinnerBeforeNextChunk = false; + + // Each chunk is one finished turn. Print what of it wasn't already printed at a + // tool call, so each turn reads in the order it happened. + void AccumulateChunk(string chunk) + { + // Peer reports carry the final completed assistant turn, rather than all + // of the narration preceding tool calls. The receiver keeps the full transcript. + if (!string.IsNullOrWhiteSpace(chunk)) _lastCompletedReply = chunk.Trim(); + var unprinted = SettleReply(chunk); + if (unprinted.Length > 0) + { + PrintReplyHeading(); + RenderMarkdownGuarded(unprinted); + } + + responseBuffer.Append(chunk); + foreach (char c in chunk) + { + if (c == '\n') + { + if (lineBuffer.ToString().Contains('\u2699')) + showSpinnerBeforeNextChunk = true; + lineBuffer.Clear(); + } + else if (c != '\r') + { + lineBuffer.Append(c); + } + } + } + + AccumulateChunk(enumerator.Current); + + var hasMore = true; + while (hasMore) + { + if (showSpinnerBeforeNextChunk) + { + showSpinnerBeforeNextChunk = false; + Spinner.Start(); + hasMore = await enumerator.MoveNextAsync(); + Spinner.Stop(); + } + else + { + hasMore = await enumerator.MoveNextAsync(); + } + + if (hasMore) + AccumulateChunk(enumerator.Current); + } + + // Every turn is on screen by now; the joined text is for history and checks. + var responseText = responseBuffer.ToString().Trim(); + if (string.IsNullOrEmpty(responseText)) + { + _lastRequestError = "The model returned no answer."; + TuiConsole.MarkupLine("[yellow]Model returned an empty response. The context may be too large for this model.[/]"); + TuiConsole.MarkupLine("[dim]Try a smaller request, or switch to a model with a larger context window via /config.[/]"); + } + else + { + _lastAiResponse = responseText; + + // Add to chat history (ANSI capture deferred to Phase 2) + _messages.Add(new ChatMsg + { + Role = "assistant", + Text = responseText + }); + if (responseText.Contains("Error: Connection to Ollama failed.", StringComparison.Ordinal) + && !await CheckOllamaConnectionAsync()) + { + _lastRequestError = "Connection to Ollama failed."; + CloseReply(); + await HandleRetryCommandAsync(); + if (_isConnected) TuiConsole.MarkupLine("[dim]Connection restored. Send your request again to continue.[/]"); + } + + // 401 auto-recovery: if the response is a 401 sign-in error, + // immediately launch the cloud sign-in walkthrough so the user + // can hit "Sign me in now" without typing /setup. Saves a step + // every single time this hits. + if (Looks401(responseText)) + { + _lastRequestError = "The model requires sign-in."; + await TryAutoSigninAfter401Async(); + } + + // Show per-response token summary on the left, matching the footer text style. + if (Config.EnableTokenTracking) + { + var lastOp = TokenTracker.LastOperation; + if (lastOp != null) + { + var inLabel = $"In {TokenTrackingService.FormatTokenCount(lastOp.PromptTokens)}"; + var outLabel = $"Out {TokenTrackingService.FormatTokenCount(lastOp.CompletionTokens)}"; + var tpsLabel = lastOp.TokensPerSecond.HasValue + ? $" Β· {lastOp.TokensPerSecond.Value:0.#} tok/s" + : ""; + var tokenSummary = $"{inLabel} Β· {outLabel}{tpsLabel}"; + TuiConsole.WriteSpaced(new Text(tokenSummary, new Style(Color.Grey62))); + } + } + + } + + // Update title bar with latest model/project/token info + ThemeService.UpdateStatusTitle(); + } + } + finally + { + Spinner.Stop(); + await enumerator.DisposeAsync(); + } + + TuiConsole.WriteLine(); + + // Spinner ownership note: ExecuteAgentModelCallAsync starts a spinner per model call + // and never stops it β€” it has always relied on the caller. Before plans were deferred + // they ran inside the enumerator drain above, so that finally cleaned up. Now they run + // out here, so the plan run needs its own guarantee or the last step's spinner keeps + // animating after everything has finished. + // + // The model's turn is fully drained. Only now do we run any plan it proposed β€” the + // plan is a peer of the chat turn, not a child of the propose_plan tool call. Running + // it here is what lets the outer stall watchdog and request ceiling stay untouched + // (they've already completed), lets the prompt gate be held normally, and leaves the + // model with no open turn afterwards to redo the work in. + try + { + await RunPendingPlanAsync(); + } + finally + { + Spinner.Stop(); + } + } + catch (OperationCanceledException) + { + _lastRequestError = "Request cancelled."; + Spinner.Stop(); + TuiConsole.WriteLine(); + TuiConsole.MarkupLine("[yellow]Request cancelled.[/]"); + TuiConsole.WriteLine(); + } + catch (Exception ex) + { + _lastRequestError = ex.Message; + Spinner.Stop(); + TuiConsole.WriteLine($"Error: {ex.Message}"); + TuiConsole.WriteLine(); + if (IsOllamaTransportFailure(ex)) + { + CloseReply(); + await HandleRetryCommandAsync(); + if (_isConnected) TuiConsole.MarkupLine("[dim]Connection restored. Send your request again to continue.[/]"); + } + } + finally + { + CloseReply(); + (Pane?.Session ?? TuiConsole.Current)?.CompleteToolTurn(); + StopCancelKeyListener(); + var oldCts = Interlocked.Exchange(ref _requestCts, null); + oldCts?.Dispose(); + + // Persist the conversation as of this turn for `mandocode --continue`. + // Runs on cancel too β€” whatever the history holds now is what "continue" means. + try { if (Pane is null || Pane.Id == 1) SessionResumeStore.Save(ProjectRoot.ProjectRoot, AI.ExportHistoryJson()); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { System.Diagnostics.Trace.TraceWarning("Could not save compacted context: {0}", ex.Message); } + SaveAgentHistory(false); + } + } + + /// + /// Renders as markdown, guarded against rendering hangs on + /// malformed/huge content. The expensive part β€” building the renderable (Markdig parse, + /// HTML walk, regex linkify) β€” runs on a background task; a 1s grace passes before a + /// "Rendering..." spinner shows, with a hard cap of + /// . On timeout or failure it + /// falls back to raw text. The console write happens ONLY on the calling thread (never + /// on the background task), so a build that overruns the timeout can't later dump a + /// second, garbled copy on top of the raw-text fallback. Shared by the direct-chat + /// response path and the per-step plan path so neither can freeze the interactive loop. + /// + private void RenderMarkdownGuarded(string text) + { + try + { + // Build off-thread but DON'T write from there. Building is the expensive, + // occasionally-pathological work; the write is fast. Keeping the write on the + // calling thread means that if the build overruns the timeout and we fall back + // to raw text, the orphaned task just finishes computing a renderable nobody + // reads and exits β€” it can't write to the console out of band and corrupt the + // view (or interleave with the next step's output). It also serializes the write + // with the surrounding imperative AnsiConsole output on this same thread. + // Large responses with many tables/code blocks can take 10+ seconds to build. + Spectre.Console.Rendering.IRenderable? renderable = null; + var buildTask = Task.Run(() => + renderable = MarkdownHtmlRenderer.BuildRenderable(text, ProjectRoot.ProjectRoot, Pane is null ? null : new CliAgentDirectory(Pane).All.Select(p => p.Name).ToArray())); + + // Show a spinner if the build takes more than 1 second. The remaining + // budget comes from config β€” big tool-grounded responses (MCP, many + // tables) can legitimately take 30+ seconds to build. + if (!buildTask.Wait(TimeSpan.FromSeconds(1))) + { + Spinner.Start("Rendering..."); + var remainingSeconds = Math.Max(1, Config.MarkdownRenderTimeoutSeconds - 1); + if (!buildTask.Wait(TimeSpan.FromSeconds(remainingSeconds))) + { + Spinner.Stop(); + // Build exceeded the configured budget β€” fall back to raw text. The + // orphaned task finishes computing into a local nobody reads, then exits + // without ever touching the console. + Console.WriteLine(); + Console.Write(text); + Console.WriteLine(); + TuiConsole.MarkupLine($"[dim](markdown rendering timed out after {Config.MarkdownRenderTimeoutSeconds}s β€” showing raw text. Raise with /config or mandocode --config set renderTimeout 120)[/]"); + return; + } + Spinner.Stop(); + } + + // Build finished within budget (a faulted build rethrows out of Wait above and + // lands in the catch below). Write the rendered markdown on this thread. + if (renderable != null) + TuiConsole.Write(renderable); + } + catch (Exception renderEx) + { + // A faulted Wait throws straight to here, skipping the Spinner.Stop() calls + // above β€” stop it so the "Rendering..." spinner doesn't leak. Stop() is idempotent. + Spinner.Stop(); + System.Diagnostics.Debug.WriteLine($"[MandoCode] Render failed: {renderEx.Message}"); + // Fallback to raw text if markdown rendering fails + Console.Write(text); + } + } + + /// + /// Called by when the model invokes propose_plan. + /// Shows the approval UI, runs execution if approved, and returns a summary + /// string that the model will see as the tool result. + /// + // Plain labels β€” ApprovalSelect carries the palette (green = proceed, warm gold = + // redirect/stop), so these stay markup-free for clean comparisons and scrollback echo. +} diff --git a/src/MandoCode/Components/App.TerminalEffects.cs b/src/MandoCode/Components/App.TerminalEffects.cs new file mode 100644 index 0000000..62dcabc --- /dev/null +++ b/src/MandoCode/Components/App.TerminalEffects.cs @@ -0,0 +1,266 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Components.Web; +using MandoCode.Models; +using MandoCode.Services; +using Spectre.Console; +using System.Text.RegularExpressions; + +namespace MandoCode.Components; + +public partial class App +{ + private void HandleRabbitCommand() + { + // Katakana-style characters for the digital rain + var rainChars = "οΎŠοΎοΎ‹ο½°ο½³ο½ΌοΎ…οΎ“οΎ†ο½»οΎœοΎ‚ο½΅οΎ˜ο½±οΎŽοΎƒοΎο½ΉοΎ’ο½΄ο½Άο½·οΎ‘οΎ•οΎ—ο½ΎοΎˆο½½οΎ€οΎ‡οΎ012345789:・.\"=*+-<>Β¦β•Œκœ"; + var rng = Random.Shared; + var width = Math.Min(Console.WindowWidth, 120); + var height = Math.Min(Console.WindowHeight - 2, 24); + + // Each column has a drop position (head of the rain streak) + var drops = new int[width]; + for (int i = 0; i < width; i++) + drops[i] = rng.Next(-height, 0); // stagger start positions + + Console.CursorVisible = false; + TuiConsole.Clear(); + + // Phase 1: Digital rain (~14 frames) + for (int frame = 0; frame < 14; frame++) + { + var sb = new System.Text.StringBuilder(); + sb.Append("\u001b[H"); // cursor home + + for (int row = 0; row < height; row++) + { + for (int col = 0; col < width; col++) + { + int dropPos = drops[col]; + if (row == dropPos) + { + // Bright head of the streak + sb.Append($"\u001b[97m{rainChars[rng.Next(rainChars.Length)]}\u001b[0m"); + } + else if (row < dropPos && row > dropPos - 6) + { + // Green trail + var fade = dropPos - row; + var intensity = fade <= 2 ? "92" : fade <= 4 ? "32" : "2;32"; + sb.Append($"\u001b[{intensity}m{rainChars[rng.Next(rainChars.Length)]}\u001b[0m"); + } + else + { + sb.Append(' '); + } + } + if (row < height - 1) sb.Append('\n'); + } + Console.Write(sb.ToString()); + + // Advance drops + for (int i = 0; i < width; i++) + { + drops[i] += rng.Next(1, 3); + if (drops[i] > height + 6) + drops[i] = rng.Next(-4, 0); + } + + Thread.Sleep(80); + } + + // Phase 2: Fade to black, then reveal the rabbit + TuiConsole.Clear(); + Thread.Sleep(300); + + var centerY = height / 2 - 1; + var rabbit = "πŸ‡"; + var quote = "Follow the white rabbit."; + var rabbitX = Math.Max(0, (width - 2) / 2); + var quoteX = Math.Max(0, (width - quote.Length) / 2); + + // Position and print rabbit + Console.SetCursorPosition(rabbitX, centerY); + Console.Write(rabbit); + + Thread.Sleep(400); + + // Print quote in green below rabbit + Console.SetCursorPosition(quoteX, centerY + 2); + Console.Write($"\u001b[32m{quote}\u001b[0m"); + + Thread.Sleep(1500); + + // Clear and return + TuiConsole.Clear(); + Console.CursorVisible = true; + } + + private static readonly string[] _matrixTriggers = + { + "what is the matrix", + "red pill", + "follow the white rabbit", + "there is no spoon" + }; + + private static bool ContainsMatrixTrigger(string input) + { + var lower = input.ToLowerInvariant(); + foreach (var trigger in _matrixTriggers) + { + if (lower.Contains(trigger)) + return true; + } + return false; + } + + private static void PlayMatrixMoment(string input) + { + var lower = input.ToLowerInvariant(); + TuiConsole.WriteLine(); + + if (lower.Contains("what is the matrix")) + { + TuiConsole.MarkupLine("[green]Unfortunately, no one can be told what the Matrix is. You have to see it for yourself.[/]"); + Thread.Sleep(1500); + } + else if (lower.Contains("there is no spoon")) + { + TuiConsole.MarkupLine("[green]Then you'll see that it is not the spoon that bends, it is only yourself.[/]"); + Thread.Sleep(1500); + } + else if (lower.Contains("red pill")) + { + TuiConsole.MarkupLine("[green]Remember, all I'm offering is the truth. Nothing more.[/]"); + Thread.Sleep(1500); + } + else if (lower.Contains("follow the white rabbit")) + { + // Small rabbit hopping across the screen + var width = Math.Min(Console.WindowWidth - 4, 60); + Console.CursorVisible = false; + for (int i = 0; i < width; i += 3) + { + Console.SetCursorPosition(i, Console.CursorTop); + Console.Write(" πŸ‡"); + Thread.Sleep(80); + Console.SetCursorPosition(i, Console.CursorTop); + Console.Write(" "); + } + Console.SetCursorPosition(0, Console.CursorTop); + Console.CursorVisible = true; + } + + TuiConsole.WriteLine(); + } + + private void HandleMusicCommand(string action) + { + // Stop visualizer before rendering status panels to avoid flicker + StopMusicVisualizer(); + + // Parse volume from "music-vol 70" style commands + if (action.StartsWith("music-vol")) + { + var parts = action.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2 && int.TryParse(parts[1], out var vol)) + { + vol = Math.Clamp(vol, 0, 100); + MusicPlayer.SetVolume(vol / 100f); + MusicPlayerUI.RenderStatus(MusicPlayer); + } + else + { + Console.WriteLine(); + Console.WriteLine(" Usage: /music-vol [0-100]"); + Console.WriteLine(" Example: /music-vol 70"); + Console.WriteLine(); + } + // Restart visualizer after panel render + StartMusicVisualizer(); + return; + } + + switch (action) + { + case "play": + MusicPlayer.Play(); + MusicPlayerUI.RenderStatus(MusicPlayer); + break; + + case "stop": + MusicPlayer.Stop(); + MusicPlayerUI.RenderStatus(MusicPlayer); + break; + + case "pause": + MusicPlayer.TogglePause(); + MusicPlayerUI.RenderStatus(MusicPlayer); + break; + + case "next": + MusicPlayer.NextTrack(); + MusicPlayerUI.RenderStatus(MusicPlayer); + break; + + case "list": + MusicPlayerUI.RenderTrackList(MusicPlayer); + break; + } + + // Restart visualizer β€” it will auto-check if music is still active + StartMusicVisualizer(); + } + + private void HandleMusicPlaySelection() + { + StopMusicVisualizer(); + + var genres = MusicPlayer.GetAvailableGenres(); + + if (genres.Count == 0) + { + TuiConsole.WriteLine(); + TuiConsole.MarkupLine("[yellow]No genres found.[/] Add mp3 files to [deepskyblue1]Audio//[/] folders."); + TuiConsole.WriteLine(); + StartMusicVisualizer(); + return; + } + + if (genres.Count == 1) + { + MusicPlayer.Play(genres[0]); + MusicPlayerUI.RenderStatus(MusicPlayer); + StartMusicVisualizer(); + return; + } + + // Build choices: title-cased genre names + Shuffle All + var choices = genres + .Select(g => char.ToUpper(g[0]) + g[1..]) + .ToList(); + choices.Insert(0, "Shuffle All"); + + TuiConsole.WriteLine(); + var selected = TuiConsole.Prompt( + new SelectionPrompt() + .Title("[deepskyblue1]Select a genre:[/]") + .HighlightStyle(SelectionHighlight) + .AddChoices(choices) + ); + + if (selected == "Shuffle All") + { + MusicPlayer.Play(null); + } + else + { + MusicPlayer.Play(selected.ToLowerInvariant()); + } + + MusicPlayerUI.RenderStatus(MusicPlayer); + StartMusicVisualizer(); + } + +} diff --git a/src/MandoCode/Components/App.razor b/src/MandoCode/Components/App.razor index ced460f..2625028 100644 --- a/src/MandoCode/Components/App.razor +++ b/src/MandoCode/Components/App.razor @@ -12,15 +12,53 @@ @if (Pane is null || Pane.Visible) { - +
-@* Banner stays always-on (it's static, no overlap risk). The dynamic section - below β€” static info + connection state + ready/help block β€” is hidden during - /setup via _setupActive so the VDOM doesn't redraw on top of the wizard's - imperative AnsiConsole output. Extracted into HomeView so the suppression is - a single @if gate rather than wrapping a long inline block. *@ +@* Keep the banner visible, but hide startup details while setup owns the screen. + HomeView keeps that visibility rule together instead of repeating it for each detail. *@ @if (!_setupActive) {