diff --git a/DOCS-TODO.md b/DOCS-TODO.md new file mode 100644 index 0000000..0d6b654 --- /dev/null +++ b/DOCS-TODO.md @@ -0,0 +1,19 @@ +# DOCS-TODO + +- P0 copy-button path: MessageCopyButton selectors replaced (aria-label/title "copy" with the case-insensitive `i` flag, `.ds-message` scoping) plus a GetByRole accessibility fallback (direct + after hover) in ClickNewestCopyControlAsync; WaitForMessageCopyButtonAsync logs the tried selectors and the `keyless doctor --capture` hint when nothing matches — selectors are UNVERIFIED until run against the live chat; verify with `keyless doctor --capture` (MessageCopyButton must be MATCHED) and confirm the next turn log shows Source "Copy". +- P1 parser repair: RepairKnownCorruptions splits close-marker+@@-command and @@end+@@-command chains onto separate lines (fixpoint, up to 3 passes); the legacy ParseBadMarker rejection test in AgentScriptParserTests was replaced by the repaired-outcome test — no docs change needed, but the AgentScript spec's format-error list should mention the auto-repair. +- P2 lenient shapes: "@@say hello" (argument becomes MSG), one outer code fence stripped from CONTENT, say-only replies no longer rejected (KA_PARSE_SAY_ONLY is now unused by the validator but stays documented in the catalog) — document the accepted one-line shapes in docs/agentscript-spec.md. +- P4 stalls: defaults ReplyTimeoutSeconds 180, SendConfirmSeconds 8, UploadWaitSeconds 15 (docs/configuration.md updated); generation waits in ResetComposerAsync/WaitForGenerationAsync capped at 10 s; IsSendEnabledAsync skips the paperclip button in one round trip; StopIndicator selectors replaced (div:has(textarea) scoping — UNVERIFIED until live chat). +- P5 minimize: no URL in the Chrome launch arguments (Chrome 136+ ignores --start-minimized with a URL); MinimizeWindowAsync falls back to Win32 ShowWindow through the launched process id — document the two-step minimize in docs/browser-automation.md. +- A1 selector-probe timeouts: EvaluateAsync hard-capped at 1.5 s; IsSendEnabledAsync skips absent selectors via an instant CountAsync (no element resolution, no EvaluateAsync) while keeping the paperclip skip and the null=undetectable contract; SetInputFiles 5 s, accessibility-hover 2 s, page.SetDefaultTimeout(5000) on every connection path as the safety net; IsSendEnabledAsync/ResetComposerAsync log a warning above 2 s (with the slowest selector index). The 60/120/180 s send/upload multiples should be gone — UNVERIFIED until a live run's --timing table shows no phase at or above 30 s. +- A2 copy-button cost: first copy wait capped at 3 s; a per-session circuit breaker (_copyButtonBroken) skips the whole copy path after the first miss (origin 'copy disabled after the first miss this session'), reset naturally by a new session/run or a doctor --capture re-check; Browser.ReplySource=Dom still short-circuits with zero waits. Copy behavior UNVERIFIED until run against the live chat (the selector may still not match — the circuit breaker only caps the cost). +- B minimize persistence: Target.createTarget now sends background=true (Chrome activated the new tab and restored the minimized window); the minimize runs IMMEDIATELY after OpenChatTabAsync (when WindowMode=Minimized and MinimizeAfterLogin) and again after login; CDP verifies via Browser.getWindowBounds read-back with a normal->minimized recovery, up to 3 attempts; the Win32 fallback was made real (ChromeWindowMinimizer: EnumWindows over Chrome_WidgetWin_1 windows owned by our chrome.exe processes — the launched PID's descendant tree or the --user-data-dir/--remote-debugging-port command-line match — then ShowWindow(SW_MINIMIZE)). FocusTabAsync/BringToFront stays login-only. UNVERIFIED until run on the live chat (window must minimize within a few seconds and STAY minimized during sends). +- Truncated replies under minimize: Chrome launch gains --disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling (Windows 11 throttles minimized windows at the feature level even with the classic flags), --disable-ipc-flooding-protection, --disable-hang-monitor — the window still minimizes, the renderer keeps streaming. WaitForReplyAsync rejects an AgentScript reply without a trailing @@eof (keeps polling silently, no correction message), logs 'Truncated reply detected (attempt N)' + a 'reply-truncated' turn event (FocusTriggered:false) per detection, and fails with KA_REPLY_TRUNCATED at the 20th detection with a hint to enable Browser.AutoFocusOnTruncation or WindowMode:Offscreen — only when AutoFocusOnTruncation=true does the window focus ONCE at the fifth detection (opt-in; default path never un-minimizes, Chrome stays minimized for the whole turn; the CLI confirmation prompt rings a terminal bell instead of raising a window). UNVERIFIED until a live run: fresh-workspace turn must end with Reply ending '@@eof', no 'Block newfile opened on line 1 is never closed' correction, and a manual mid-turn minimize must still produce a complete reply. +- Chrome window show/hide: IChromeWindowController (ChromeWindowController.cs) moves the window by pure CDP setWindowBounds (show: normal @100,100 1280x900 + Target.activateTarget; hide: normal @-32000,-32000; optional --peek returns offscreen after N seconds) — 'keyless show/hide ', a Show Chrome button on the doctor page, and 'keyless chrome' now show the window after connecting. The login flow shows the window instead of BringToFront (FocusTabAsync remains only as the IChatPage contract, unused by the provider). UNVERIFIED until live: show/hide during a running turn must not disturb send-attempt timing, and the Offscreen login flow must be visible. +- True-minimize root cause (turn-16): WindowMode default was still 'Minimized' and the MinimizeAfterLogin path sent CDP windowState:'minimized', which Chrome throttles regardless of startup position — the stream then truncated 20x. Now: WindowMode default 'Offscreen'; 'Minimized' is backward-compatible and ALSO means offscreen (launch flags --window-position=-32000,-32000 --window-size=1280,900); HideWindowAsync (renamed from MinimizeWindowAsync) moves the window offscreen with windowState:'normal' and verifies via getWindowBounds read-back (left/top -32000), and the Win32 fallback uses SW_HIDE instead of SW_MINIMIZE. Doctor page has Start/Show/Hide Chrome buttons. UNVERIFIED until live: zero reply-truncated on a full turn, taskbar click produces nothing while offscreen, show at (100,100) 1280x900 not fullscreen. +- Offscreen STILL throttles (turn-17: 20 truncations with the occlusion flag present; turn-18: visible window OK, hidden -> truncating again): Chrome treats fully offscreen positions as occluded regardless of --disable-features=CalculateNativeWinOcclusion. Fix: WindowMode default 'TinyCorner' (80x80 square, bottom-right of the REAL primary screen bounds via GetSystemMetrics, never hardcoded); new modes TinyCorner/EdgePeek (10x10 sliver, may still be throttled); every hide path (MinimizeWindowAsync successor, keyless hide, peek return) now moves to the same tiny corner instead of -32000; the launch command line is logged and emitted as a 'chrome-launched' turn event; 'keyless diagnostics chrome' prints the running Chrome's command line (Get-CimInstance) with a flag verdict. UNVERIFIED until live: TinyCorner/EdgePeek truncation counts (expected zero / near-zero), the visible 80x80 square during a turn, and whether EdgePeek's 10x10 passes Chrome's occlusion heuristic. +- selectors.json versioning: CatalogVersion 2 in the embedded default + MergeAsync in SelectorsCatalog - a workspace file with an older/missing version is backed up (selectors.json.v.bak) and merged: keys whose value matches ANY version ever shipped (ShippedValues table generated from git history; MessageCopyButton/StopIndicator/SendButton/DeepThinkToggle/SearchToggle/MessageItems) are refreshed to the current embedded values, genuine customizations and workspace-only keys survive, missing keys added; keyless selectors refresh forces the merge; the copy-button miss warning points at it. UNVERIFIED until live: a real turn in an OLD workspace must show reply-source Source: Copy (the circuit breaker resets per session). +- Truncation budget is WALL-CLOCK now: the old fixed 20-attempt count burned out in ~2-4s (detections ran back-to-back with no poll delay) while a stream that resumed after ~8s was still killed. New Browser.TruncationBudgetSeconds (default 20): the wait fails with KA_REPLY_TRUNCATED only when the time since the FIRST truncation detection exceeds it; detections are spaced by the normal poll delay (no busy-loop); the reply-truncated event carries ElapsedSeconds; AutoFocusOnTruncation fires at 25% of the budget instead of attempt 5. UNVERIFIED until live: an 8-10s slow stream must now complete instead of failing, and ElapsedSeconds must grow in step with real time in turn-N.jsonl. +- DPI + launch-geometry diagnostics: a live chrome-launched log showed the RAW screen size (1840,1000 on 1920x1080) despite the SPI_GETWORKAREA fix - the process was not DPI-aware, so the screen APIs returned OS-virtualized values at non-100% scale. The CLI now embeds an app.manifest with dpiAwareness=PerMonitorV2 (ApplicationManifest in the csproj; verified embedded in the exe) and the WPF csproj sets ApplicationHighDpiMode=PerMonitorV2. Chrome launches now log raw screen vs work area side by side (plus RawScreenSize/WorkAreaSize fields in the chrome-launched turn event), and after the launch attach the connector locates the real HWND (FindOurChromeWindows, polled up to 3s), GetWindowRects it, logs actual-vs-requested (8px tolerance, MISMATCH warning on drift, chrome-window-bounds turn event). UNVERIFIED until live on the test machine: Raw/WorkArea differ by the taskbar height, --window-position uses work-area coordinates, actual GetWindowRect matches within pixels, and the 80x80 square is visibly on the desktop - note the machine resolution + scale factor first. +- Chrome CLOSE path: IChromeWindowController.CloseAsync (options, allowAttached=false) - graceful CDP Browser.close (whole browser, profile flushed), the debug port polled ~3s to confirm, then a tree-kill fallback (Process.Kill(entireProcessTree)) of every chrome.exe whose command line carries OUR profile dir; a Chrome matched only by our port (user-launched, no profile) is REFUSED unless allowAttached - same ownership rule as AttachedToRunningChrome. Ownership matches RUNNING command lines (ChromeWindowMover.GetChromeProcessInfos), so it works after an app restart without a session PID. WPF: Close Chrome button (4th command, cross-disabling) + App.OnExit always-on safety net (4s bounded, distinct from CloseChromeOnExit which still only gates per-turn session disposal). UNVERIFIED until live: start-via-app -> normal app exit leaves no chrome.exe for the profile; Close Chrome button then Start Chrome relaunches cleanly; a manually-attached Chrome is untouched in both cases. +- Snapshot size budget: Snapshot.Mode defaults to TreeAndChanged (Full still supported); a new total ceiling MaxTotalAttachmentChars (400k chars) caps EVERYTHING attached to one turn - when exceeded the tree stays complete, the dump keeps only request-referenced files, and everything else becomes per-folder omission notes + a snapshot-budget-exceeded turn event (original total, ceiling, omitted count/chars). TreeAndChanged no-baseline/empty-diff falls through the SAME budget path (first turn is never an unlimited full dump). ExcludeFilePatterns (*.min.js/*.min.css/lock files/designer/migration snapshots) drop generated files from the attached dump; MaxDumpedFileChars (50k) replaces oversized files with heading + placeholder. Change-stash: the TreeAndChanged diff is computed at refresh time BEFORE the baseline is overwritten (the old code diffed against a just-captured baseline, so changed-files.txt could never list real changes). UNVERIFIED until live: a real first turn on a large workspace stays under the ceiling with referenced files present, and the second turn shows the changed-files diff. \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 4adb552..a1db34e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,36 +1,34 @@ - - - net10.0 - enable - enable - latest - latest - All - false - false - true - false - false - true - - - - $(NoWarn);CA1873;CA2007;CA1031;CA1040;CA1303;CA1307;CA1308;CA1822;S3267;CA1707;S1135;CA1861;CA1056;CA1054;CA2000;CA5394;S1075;S2245 - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - + $(NoWarn);CA1873;CA2007;CA1031;CA1040;CA1303;CA1307;CA1308;CA1822;S3267;CA1707;S1135;CA1861;CA1056;CA1054;CA2000;CA5394;S1075;S2245 + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + @@ -45,6 +43,5 @@ true https://github.com/ImanEstiri/KeylessAgent README.md - - + \ No newline at end of file diff --git a/KeylessAgent.slnx b/KeylessAgent.slnx index d49635b..1453e12 100644 --- a/KeylessAgent.slnx +++ b/KeylessAgent.slnx @@ -1,7 +1,18 @@ - - - - - + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index eae7af8..cadc0ae 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,11 @@ keyless doctor "G:\Projects\MyApp" # live sel keyless run "G:\Projects\MyApp" "..." --dry-run # print what would happen, execute nothing keyless run "G:\Projects\MyApp" "..." --verbose # (-v) write .agent/logs/diagnostics.log keyless run "G:\Projects\MyApp" "..." --timing # print the per-phase timing table after the turn +keyless run "G:\Projects\MyApp" "..." --pace Step # one command per response (Step | Balanced | Batch) +keyless run "G:\Projects\MyApp" "..." --refresh-env # re-probe git/dotnet/python/node instead of the 24 h cache ``` -The interactive session understands `/workspace ` (switch workspaces), `/newchat` (fresh chat, same workspace) and `/help`. +The interactive session understands `/workspace ` (switch workspaces), `/newchat` (fresh chat, same workspace), `/tools` / `/refreshenv` (show / re-probe the installed tools) and `/help`. **Exit codes** for `keyless run` / `keyless resume`: @@ -114,6 +116,34 @@ The interactive session understands `/workspace ` (switch workspaces), `/n The first time a command runs in a workspace it creates `keyless.json` (configuration) and `selectors.json` (editable chat selectors) — see [docs/configuration.md](docs/configuration.md). +## Desktop app (WPF) + +KeylessAgent ships a WPF desktop UI ([docs/wpf-ui.md](docs/wpf-ui.md)) built on the same Core +loop — no API key, no second brain: everything the CLI does, in windows instead of a console. + +- **Agent page** — activity feed (progress steps, model messages, commands), a multiline composer with dry-run toggle, Send/Stop (Ctrl+Enter / Esc), a changes panel with a collapsible unified diff viewer (CRLF-safe, large-file fast path), the turn summary card and one-click undo of the newest backup batch (with a confirmation dialog). +- **Workspace page** — folder picker, recent workspaces (max 10, persisted), and git capability inspection (git installed? repository? branch? agent branch?). +- **Doctor page** — the live selector check from `keyless doctor` with status chips and a Start-Chrome button; the connection kind shows up in the status bar. +- **Settings page** — the workspace's `keyless.json` read-only (grouped), open/reload buttons, plus UI preferences: Reduce effects, backdrop (Auto/Mica/Acrylic/None), dry run by default. +- **About page** — version, description and the terms-of-service notice. +- **Shell** — Windows 11 Mica/Acrylic backdrop with an app-drawn fallback, custom title bar, queued modal dialogs (one at a time, cancelled when you stop a turn), Persian/Arabic composer direction detection, window size/position persistence with off-screen protection, and a crash log at `%LOCALAPPDATA%\KeylessAgent\wpf-crash.log`. + +```bash +dotnet run --project src/UI/ImanSoftware.KeylessAgent.Wpf +``` + +Requirements: Windows plus the .NET 10 SDK (to run from source); the published app needs the .NET 10 **Desktop Runtime**. On Windows 11 22H2+ the window uses the system Mica/Acrylic backdrop (Auto/Mica/Acrylic/None selectable in Settings); older systems get an app-drawn gradient. + +Publish: + +```powershell +powershell -File src\UI\ImanSoftware.KeylessAgent.Wpf\publish-wpf.ps1 +``` + +This publishes into `artifacts\wpf` (framework-dependent, win-x64), verifies the Playwright driver files are present, and reports a summary. Screenshot placeholders: *(to be added)*. + +> Verification status: the WPF app is covered by 201 automated tests and a warning-free build, and the publish flow was executed successfully — but **nobody has visually reviewed the running app yet**. Expect rough edges; visual feedback is very welcome. + ## How it works ```mermaid @@ -166,7 +196,9 @@ MSG>>> @@eof ``` -Commands: `say`, `newfile`, `updatefile`, `editfile` (FIND/REPLACE, must match exactly once), `deletefile`, `readfile`, `runcommand`, `ask`, `commit`, `done`. Everything the model writes is English; file content may be any language. No outer Markdown fence. +Commands: `say`, `newfile`, `updatefile`, `editfile` (FIND/REPLACE, must match exactly once), `mkdir`, `movefile`, `deletefile`, `readfile`, `runcommand`, `ask`, `commit`, `next`, `done`. Everything the model writes is English; file content may be any language. No outer Markdown fence. + +Successful file commands carry a `verified:` line (path, bytes, lines, SHA-8, first/last snippets) in the results — the model can trust the write landed without re-reading the file. See the full grammar, pacing and the error-code catalog in [docs/agentscript-spec.md](docs/agentscript-spec.md). ## Reply sources @@ -192,6 +224,34 @@ The reasoning block is never read, logged, or sent anywhere. See [docs/browser-a Details: [docs/security.md](docs/security.md) and [docs/git-workflow.md](docs/git-workflow.md). +## Tools and missing-tool behavior + +At session start the agent probes `git`, `dotnet`, `node`, `npm` and `python` (resolving `python` / `py -3` / `python3`, and flagging WindowsApps store aliases) and prints one line: + +```text +Tools: git OK, dotnet OK, python MISSING (https://www.python.org/downloads/), node MISSING (https://nodejs.org), npm MISSING (https://nodejs.org) +``` + +`runcommand`s whose program the environment lists as missing are **never started**: the result comes back as `KA_TOOL_MISSING` ("…Do not retry it. Use the file commands, or ask the user to install it.") and counts as a normal failed command. The probe is cached for 24 h, re-checked per tool after 1 h, and auto-invalidated when reality disagrees with the cache. `run --refresh-env` and `/refreshenv` re-probe manually. + +## Offline and composer reset + +Before every send the agent checks `navigator.onLine` plus a cheap probe of the chat origin. Offline: nothing is uploaded or sent — the result is `KA_NETWORK_OFFLINE` ("Check your connection and try again."), optionally after waiting `Browser.WaitForNetworkSeconds` for the connection. A drop during upload, send-confirmation or the reply wait stops the wait at once, cleans the composer, and reports the same code; the chat session stays valid, so you can resend the same request. + +Before every message the composer is reset to a clean state: leftover text is cleared and stale attachment chips (failed ones first) are removed, with a page reload as fallback. After a failed or cancelled attempt with attachments, the same cleanup runs best-effort, so the next message never inherits stale chips. + +## Pace modes + +`Agent.Pace` caps how many non-`say` commands one response may carry — **Step** = 1, **Balanced** = 3 (default), **Batch** = `Limits.MaxCommandsPerResponse`; the effective limit is `min(Pace, MaxCommandsPerResponse)`: + +| Pace | Per response | Prompt rule | +| --- | --- | --- | +| `Step` | 1 command | One command, then `@@next`; verify the result (verification summary or `readfile`) before the next step. Iterations raised to ≥ 60. | +| `Balanced` | 3 commands | Group at most 3 related commands, verify after each group. | +| `Batch` | `Limits.MaxCommandsPerResponse` | Unchanged batch behavior. | + +Extra commands are skipped with `pace=: only N command(s) per response; send the next one after reading this result`. Set it in `keyless.json` (`"Agent": { "Pace": "Step" }`) or per turn with `keyless run … --pace Step`. + ## Known limitations - **Windows only** for now (the snapshot analyzer and process launching assume Windows). The engine itself is portable. @@ -218,12 +278,13 @@ KeylessAgent/ │ │ ├── ImanSoftware.KeylessAgent.Abstractions/ # models, enums, service contracts, IAgentUserInterface │ │ └── ImanSoftware.KeylessAgent.Core/ # parser, executors, git, snapshot, browser, agent loop, factory │ ├── UI/ -│ │ └── ImanSoftware.KeylessAgent.Cli/ # the `keyless` dotnet tool (Spectre.Console.Cli) +│ │ ├── ImanSoftware.KeylessAgent.Cli/ # the `keyless` dotnet tool (Spectre.Console.Cli) +│ │ └── ImanSoftware.KeylessAgent.Wpf/ # WPF desktop UI (MVVM) + publish-wpf.ps1 │ ├── Playground/ # manual-testing console app (fake chat provider) -│ └── tests/ # xunit + FluentAssertions (247 tests) -├── docs/ # architecture, AgentScript spec, configuration, security, … +│ └── tests/ # xunit + FluentAssertions (733 tests: Core/CLI + WPF) +├── docs/ # architecture, AgentScript spec, configuration, wpf-ui, … ├── Directory.Build.props / Directory.Packages.props # shared properties + Central Package Management -└── publish.ps1 # packs the tool into artifacts/ +└── publish.ps1 # packs the CLI tool into artifacts/ ``` ## Building from source diff --git a/code-dump.bat b/code-dump.bat index c5e248f..329947d 100644 --- a/code-dump.bat +++ b/code-dump.bat @@ -31,7 +31,7 @@ set "OUT=%ROOT%\code-dump-result.md" set "SELF=%~f0" set "EXTENSIONS=.razor .css .cs .js .ts .html .cshtml .json .scss .less .xaml .config .csproj .aspx .master .sql .txt .text .md .bat .ps1" -set "EXCLUDE_DIRS=\bin\ \obj\ \node_modules\ \.git\ \.vs\ \.idea\ \wwwroot\lib\" +set "EXCLUDE_DIRS=\bin\ \obj\ \node_modules\ \.git\ \.vs\ \.idea\ \wwwroot\lib\ \tests\ \docs\ \artifacts\" set "GT=>" set /a TOTAL=0 diff --git a/docs/WPF-UI-BUILD-GUIDE.md b/docs/WPF-UI-BUILD-GUIDE.md new file mode 100644 index 0000000..8fa9cce --- /dev/null +++ b/docs/WPF-UI-BUILD-GUIDE.md @@ -0,0 +1,528 @@ +# KeylessAgent WPF UI — Build Guide (for the AI agent) + +You are going to add a **beautiful desktop UI (WPF)** to the KeylessAgent solution. This guide tells you exactly what to build, in which order, and how to check your own work. **Read sections 0 to 5 completely before you write anything. Then do ONLY the phase the user asks for.** + +--- + +## 0. How to work (read this first, these rules are mandatory) + +1. The user will say "Do Phase N". Do **only** that phase. When it is finished, stop with `@@done` and report. Never start the next phase by yourself. +2. You can send at most **5 commands per reply**. Create **one file per command**, keep every file **under ~200 lines** (split big XAML into several resource dictionaries or user controls). Use `@@next` between replies while you work, and `@@done` only when the phase is finished. +3. After every phase run `dotnet build` for the WPF project and `dotnet test` for its test project. Fix errors **one at a time** (read the first error, fix it, build again). Do not rewrite files you do not have to. +4. **Never run the WPF app.** Do not run `dotnet run` for the UI project, do not start `keyless`, do not start Chrome. The app opens a window and blocks forever. You cannot see the screen. Never say "the UI looks good": you can only say "it builds and the tests pass". The user checks the look. +5. **Do not edit** the Core projects (`ImanSoftware.KeylessAgent.Abstractions`, `ImanSoftware.KeylessAgent.Core`), the CLI project, or the Core tests, unless a phase explicitly says so. If you think Core needs a change, stop and report what and why. +6. Do not add NuGet packages. WPF needs none. (If you are really blocked, ask the user first.) If a package is ever allowed, add it with `dotnet add package `, never write a version number in a `.csproj` (the solution uses Central Package Management). +7. Do not use `async void` (except WPF event handlers), do not call `.Result` / `.Wait()` / `.GetAwaiter().GetResult()` on the UI thread. +8. Keep code-behind (`*.xaml.cs`) tiny: only view-only things (window dragging, focus, backdrop). All logic goes into ViewModels and services so it can be unit tested. +9. Every public type gets an XML doc comment (`/// `). Follow the style of the existing code (file-scoped namespaces, `internal sealed` where possible, `Outcome` results for expected failures). +10. If analyzer warnings come from generated XAML code, add a **targeted** `` in the WPF `.csproj` with a comment. Never lower rules in `Directory.Build.props`. +11. Add one bullet per change to `DOCS-TODO.md` in the repo root (create it if missing). Do not edit `README.md` or `docs/` before Phase 12. +12. Existing code is the best documentation. Before writing something, **read the matching CLI file** (listed in section 4). Copy the *idea*, not the Spectre.Console code. + +--- + +## 1. Goal and non-goals + +**Goal:** a Windows desktop app, `ImanSoftware.KeylessAgent.Wpf`, that does everything the CLI does, with a modern **dark, elegant, partly glass-like** look ("midnight and champagne gold"): + +- choose a workspace (a git folder), type a request, press **Send**; +- watch the agent work as a live, animated activity feed (steps, messages from the model, commands, diffs); +- approve or decline commands and file changes in nice in-window dialogs (no console prompts); +- see the result of the turn (changed files, branch, timing), undo, run the doctor, view the settings. + +**Non-goals (do not build):** a code editor, a terminal emulator, a Git GUI, themes other than dark + (optional) light, localization, auto-update, installer. + +--- + +## 2. Facts about the environment + +- Windows 10/11, .NET 10 SDK. WPF is included in the SDK on Windows. The new project must use `net10.0-windows`, `true`, `WinExe`. (The solution's `Directory.Build.props` probably sets `net10.0`; the new project must override it in its own `.csproj`.) +- The solution file is `KeylessAgent.slnx`. Add the new projects with `dotnet sln KeylessAgent.slnx add `. If that command fails, open the `.slnx`, copy the style of the existing `` entries and add yours by hand. +- Central Package Management is on: package versions live only in `Directory.Packages.props`. +- Tests use **xunit v2**, **FluentAssertions 7.x**, `coverlet.collector`, `Microsoft.NET.Test.Sdk`. Copy the style of `src/tests/ImanSoftware.KeylessAgent.Tests/ImanSoftware.KeylessAgent.Tests.csproj` (its package references have no versions). +- Unverified assumptions you must **check** instead of trusting: the exact content of `Directory.Build.props` (analyzers, `TreatWarningsAsErrors`, language version), whether `dotnet sln` supports `.slnx` in the installed SDK. + +--- + +## 3. Architecture + +### 3.1 Folders + +```text +src/UI/ +├── ImanSoftware.KeylessAgent.Cli/ (exists, do not touch) +└── ImanSoftware.KeylessAgent.Wpf/ (NEW) + ├── ImanSoftware.KeylessAgent.Wpf.csproj + ├── App.xaml / App.xaml.cs + ├── Mvvm/ ObservableObject, RelayCommand, AsyncRelayCommand + ├── Infrastructure/ IUiDispatcher, WpfDispatcher, WindowBackdrop, BackdropPlanner, + │ SettingsService, CrashLogger, TextDirection + ├── Services/ IDialogService + DialogService, WpfUserInterface, AgentRunner, + │ EventPresenter, LineDiff + ├── ViewModels/ Shell, Agent, Workspace, Doctor, Settings, About, DialogHost, Feed items + ├── Views/ ShellWindow, AgentView, WorkspaceView, DoctorView, SettingsView, AboutView, + │ dialogs and feed item controls + ├── Themes/ Colors.xaml, Brushes.xaml, Typography.xaml, Glass.xaml, Controls.xaml, + │ Animations.xaml + └── Converters/ small value converters +src/tests/ +└── ImanSoftware.KeylessAgent.Wpf.Tests/ (NEW, net10.0-windows) +``` + +### 3.2 Dependency direction + +`Wpf` → `Core` (project reference only). The UI knows `IAgentLoop`, `IAgentUserInterface` and the models; **Core never knows the UI**. ViewModels must not use WPF types such as `Brush`, `Window` or `MessageBox` (use enums, strings and plain numbers; the XAML maps them to brushes). That keeps them testable. + +### 3.3 Threading model (very important) + +Core runs on thread-pool threads. It can call **any** member of `IAgentUserInterface` from **any thread**, even at the same time. + +- `Say`, `ShowProgress`, `ShowDiff`: fire and forget. Post them to the UI thread (`IUiDispatcher.Post`). Never block the caller. +- `ConfirmAsync`, `AskAsync`, `ChooseAsync`: return a `Task` that completes when the user answers in a dialog. Show **one dialog at a time** (queue the others with a `SemaphoreSlim`). The dialog lives on the UI thread; the `Task` is completed through a `TaskCompletionSource`. +- `BeginWait(message)`: show a busy indicator with the message and return an `IDisposable`. Disposing it hides that message. Calls may nest. +- When the user presses **Stop**: cancel the `CancellationTokenSource` of the turn **and** close any open dialog (complete it as declined/cancelled) so nothing hangs. +- Use an `IUiDispatcher` interface (`Post(Action)`, `InvokeAsync(Func)`) so tests can use an `ImmediateDispatcher`. + +--- + +## 4. The Core API you may use (exact names) + +Read these files before using them (they are in the repo): `src/UI/ImanSoftware.KeylessAgent.Cli/Helpers/AgentCliRunner.cs`, `ConsoleUserInterface.cs`, `InteractiveSession.cs`, `Commands/Chrome/ChromeCommand.cs`, `Commands/Doctor/DoctorCommand.cs`, `Commands/Undo/UndoCommand.cs`, `Commands/Config/ConfigCommand.cs`. + +```csharp +// Abstractions: the contract your UI must implement +public interface IAgentUserInterface +{ + void Say(string message); // a message from the model to the user + void ShowProgress(AgentProgressEvent progressEvent); + Task ConfirmAsync(ConfirmRequest request); // yes / no + Task AskAsync(string question); // free text answer + void ShowDiff(FileChange change); // a file is about to change + Task ChooseAsync(ChoiceRequest request, CancellationToken cancellationToken = default); + IDisposable BeginWait(string message); +} + +public sealed record AgentProgressEvent(AgentEventType Type, string Detail); +public enum AgentEventType { + SessionStarting, ConnectingToBrowser, OpeningChat, UploadingAttachments, SendingMessage, + WaitingForReply, ValidatingReply, RequestingResend, ExecutingCommand, AskingUser, + SendingResults, RefreshingSnapshot, TurnFinished } + +public sealed record ConfirmRequest(string Title, string Details); +public sealed record ChoiceOption(string Id, string Label, string? Description = null); +public sealed record ChoiceRequest(string Title, IReadOnlyList Options, string DefaultId); +public sealed record ChoiceResult(string? SelectedId, bool Cancelled); +public enum FileChangeKind { Create, Update, Edit, Delete } +public sealed record FileChange(string Path, FileChangeKind Kind, string? OldContent, string? NewContent); + +public interface IAgentLoop { + Task RunAsync(string workspaceRoot, string request, IAgentUserInterface ui, + bool dryRun = false, CancellationToken cancellationToken = default); +} +public sealed record AgentTurnResult { // all init-only + string Branch; int Turn; IReadOnlyList Commits; string? Summary; bool CompletedByDone; + string? StoppedReason; string? ErrorMessage; string? ErrorCode; bool DryRun; + IReadOnlyList ChangeSet; // ChangedPath has Kind and Path + string? Timings; int AutoApprovedCommands; int ConfirmedCommands; string DiffStat; } +``` + +Creating services (static factory, no DI container): + +```csharp +using ImanSoftware.KeylessAgent.Core.Factory; +var loop = KeylessAgentServiceFactory.CreateAgentLoop(loggerFactory /* may be null */); +var config = KeylessAgentServiceFactory.CreateConfigurationService(); // LoadAsync(workspaceRoot) -> Outcome +var backups = KeylessAgentServiceFactory.CreateBackupService(); // UndoLatestAsync(workspaceRoot) -> Outcome> +var sessions = KeylessAgentServiceFactory.CreateSessionStore(); +var inspector = KeylessAgentServiceFactory.CreateWorkspaceInspector(); // read its interface for the exact method +var doctor = KeylessAgentServiceFactory.CreateChatDiagnostics(); // read DoctorCommand.cs for the exact call +var connector = KeylessAgentServiceFactory.CreateBrowserConnector(); // read ChromeCommand.cs for the exact call +var logs = KeylessAgentServiceFactory.CreateFileLoggerFactory(workspaceRoot); // verbose diagnostics +``` + +`Outcome` (namespace `ImanSoftware.Outcomes`): `IsSuccess`, `IsFailure`, `Data` (**throws** if failed, check first), `Error.Message`, `Error.Code`. +A turn that is cancelled throws `OperationCanceledException`. A turn that fails returns a result with `ErrorMessage` set. A turn stopped early has `StoppedReason`. + +--- + +## 5. Design language (follow it exactly) + +Name: **Midnight & Champagne**. Dark, calm, elegant. Gold is used sparingly (primary button, focus, active nav item, small highlights). + +### 5.1 Colors (put them in `Themes/Colors.xaml`, brushes in `Brushes.xaml`; use `DynamicResource` in views) + +| Token | Value | Use | +|---|---|---| +| `BgDeep` | `#080B16` | window base | +| `BgMid` | `#0E1428` | fallback gradient end | +| `GlassFill` | `#0FFFFFFF` (6 % white) | normal glass panel | +| `GlassFillHover` | `#1AFFFFFF` (10 %) | hover | +| `GlassFillStrong` | `#26FFFFFF` (15 %) | dialogs, composer | +| `GlassBorder` | `#24FFFFFF` (14 %) | 1 px panel border | +| `TextPrimary` | `#F2F4FA` | main text | +| `TextSecondary` | `#A9B1CC` | secondary text | +| `TextMuted` | `#6F7898` | hints | +| `Gold` / `GoldHover` / `GoldPressed` | `#E3C07A` / `#F0D29A` / `#C9A35F` | accent | +| `Violet` | `#8B7CFF` | secondary accent | +| `Success` / `Warning` / `Danger` / `Info` | `#5CD6A0` / `#F5B94C` / `#F26D7D` / `#6CB4FF` | status | +| `DiffAdd` / `DiffRemove` | `#265CD6A0` / `#26F26D7D` | diff row backgrounds | + +### 5.2 Typography, spacing, shape + +- Fonts: `Segoe UI Variable Text, Segoe UI` for text; `Cascadia Mono, Consolas` for code and commands. Sizes: caption 12, body 14, subtitle 16, title 22, display 28. Titles are semi-bold. +- 4-point spacing grid: 4, 8, 12, 16, 24, 32. Corner radius: cards 16, controls 10, pills 999. +- Symbols: use plain Unicode that exists in Segoe UI Symbol (✓ ✕ ⚠ ● ▶ ■ ›). Do not invent icon-font codes. + +### 5.3 The glass recipe (what "glass" means here) + +WPF cannot blur what is behind an arbitrary panel cheaply, so glass is built from **four layers**: + +1. **System backdrop (Windows 11 build 22621+):** Mica or Acrylic behind the whole window via `DwmSetWindowAttribute` (see Appendix A). On older Windows, or when "Reduce effects" is on, show a dark gradient (`BgDeep` → `BgMid`) instead. +2. **Blurred orbs:** a few large, soft, colored ellipses (violet, gold, teal, low opacity) with `BlurEffect Radius=90..120`, placed behind the content in a non-hit-testable `Canvas`. They make the translucent panels look frosted. +3. **Glass panels:** `Border` with a diagonal white gradient fill (about 12 % to 4 %), a 1 px `GlassBorder`, radius 16, and a soft shadow only on a few big surfaces. +4. **Motion:** short fades and slides (150 to 250 ms) when feed items appear, hover transitions, a pulsing dot while the agent is busy. + +Performance rule: blur and shadow effects only on the orb layer and on at most 3 to 4 large surfaces. A **Reduce effects** setting removes orbs, shadows and animations. + +### 5.4 Screens (wireframes) + +```text +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ◆ KeylessAgent [ Ask ▾ ] ● Chrome connected ─ ▢ ✕ │ title bar (48 px, draggable) +├────────┬─────────────────────────────────────────────────────────────────────┤ +│ ▶ │ Workspace: G:\Projects\MyApp ⎇ agent/2026… [Change…] │ header (glass) +│ Agent ├───────────────────────────────────────────────────┬─────────────────┤ +│ ▣ │ ┌ you ─────────────────────────────┐ │ Changes │ +│ Work- │ │ create a folder named y │ │ ✚ y/… │ +│ space │ └──────────────────────────────────┘ │ ✎ README.md │ +│ ✚ │ › Connecting to the chat… ✓ │ │ +│ Doctor │ › Waiting for reply… ● │ Summary │ +│ ⚙ │ ┌ model ───────────────────────────┐ │ Branch, turn, │ +│ Settings│ │ I will create the folder. │ │ commands, │ +│ ⓘ │ └──────────────────────────────────┘ │ timing │ +│ About │ ▶ #1 runcommand mkdir y ✓ approved │ │ +│ ├───────────────────────────────────────────────────┴─────────────────┤ +│ │ ┌ composer (glass) ────────────────────────────────────────────┐ │ +│ │ │ Type your request… [ Dry run ☐ ] [ Send ▶]│ │ +│ │ └──────────────────────────────────────────────────────────────┘ │ +├────────┴─────────────────────────────────────────────────────────────────────┤ +│ Ready · Turn 9 · 00:12 │ status bar +└──────────────────────────────────────────────────────────────────────────────┘ +``` + +Dialogs are **overlays inside the window** (dimmed backdrop, centered glass card), never separate windows. + +--- + +## 6. Phases + +Each phase ends with: build ok, tests ok, one `@@done` report. The report format is in section 9. + +### Phase 1 — Skeleton and solution wiring + +**Create** +- `src/UI/ImanSoftware.KeylessAgent.Wpf/ImanSoftware.KeylessAgent.Wpf.csproj` (Appendix B), `App.xaml`, `App.xaml.cs`, `Views/ShellWindow.xaml(.cs)` showing only the text "KeylessAgent". +- `src/tests/ImanSoftware.KeylessAgent.Wpf.Tests/ImanSoftware.KeylessAgent.Wpf.Tests.csproj` with one smoke test (`true.Should().BeTrue()` is not allowed: test that `new ObservableObject()`-like type exists after Phase 4; for now test that the assembly loads: `typeof(App).Assembly.GetName().Name`). +- Add both projects to `KeylessAgent.slnx`. + +**Done when:** `dotnet build KeylessAgent.slnx` has 0 errors and 0 warnings, the Core tests still pass, the new test passes. + +### Phase 2 — Theme (colors, brushes, glass, controls) + +**Create** in `Themes/`: `Colors.xaml`, `Brushes.xaml`, `Typography.xaml`, `Glass.xaml` (styles `GlassCard`, `GlassCardStrong`, `Pill`), `Controls.xaml` (restyled `Button` with variants `PrimaryButton` (gold), `GhostButton`, `DangerButton`; `TextBox`, `ComboBox`, `CheckBox`/toggle switch, `ScrollBar` (thin, rounded), `ToolTip`, `ListBoxItem`), `Animations.xaml` (storyboards `FadeIn`, `SlideUpFadeIn`, `PulseDot`). Merge them in `App.xaml` in this order: Colors, Brushes, Typography, Glass, Controls, Animations. Define brushes **before** the styles that use them. + +Also create `Views/StyleGalleryWindow.xaml` (opened only from the About page in Debug builds) that shows every style once, so the user can eyeball the theme. + +**Details:** all colors come from the tokens in 5.1. Every interactive control has normal, hover, pressed, focused (gold 2 px ring) and disabled states. Use `ControlTemplate` + `VisualStateManager` or `Triggers`, whichever you can write without errors. + +**Tests:** a test that loads every dictionary with `new ResourceDictionary { Source = ... }` is **not** possible without an `Application`; instead write a pure test that every token name in section 5.1 appears in `Colors.xaml` (read the file as text). + +### Phase 3 — Window shell, chrome and backdrop + +**Create:** `Infrastructure/WindowBackdrop.cs` and `Infrastructure/BackdropPlanner.cs` (Appendix A), the real `ShellWindow.xaml` (custom title bar with minimize/maximize/close buttons, sidebar with five nav items, content area `ContentControl`, status bar), the four layers from 5.3 (fallback gradient, orb canvas, content). + +**Rules** +- `WindowStyle="None"`, `Background="Transparent"`, **`AllowsTransparency` must stay false** (it breaks the system backdrop), `WindowChrome` with `GlassFrameThickness="-1"`, `CaptionHeight="48"`, `ResizeBorderThickness="6"`. +- Title-bar buttons need `WindowChrome.IsHitTestVisibleInChrome="True"`. +- When maximized, add padding equal to `SystemParameters.WindowResizeBorderThickness` to the root grid so content is not cut off. +- `BackdropPlanner.Decide(osBuild, preference, reduceEffects)` is a **pure function** returning `{ Backdrop: None|Mica|Acrylic, ShowOrbs, ShowFallbackGradient, Animations }`. Unit test it (Windows 10, build 22000, 22621, reduce effects on/off). +- The backdrop is applied in `OnSourceInitialized` (needs the window handle). If it fails (returns false), show the fallback gradient. It must never throw. + +**Done when:** build + tests pass. (Visual check is the user's.) + +### Phase 4 — MVVM, navigation, settings service + +**Create:** `Mvvm/ObservableObject.cs` (`SetProperty`, `OnPropertyChanged`), `Mvvm/RelayCommand.cs`, `Mvvm/AsyncRelayCommand.cs` (disables itself while running, exposes `IsRunning`, catches exceptions and reports them through an `Action`), `ViewModels/ShellViewModel.cs` (current page, nav items, `NavigateCommand`), `Infrastructure/IUiDispatcher.cs`, `WpfDispatcher.cs`, `Infrastructure/SettingsService.cs`. + +`SettingsService` stores `%APPDATA%\KeylessAgent\wpf-settings.json`: +`{ "LastWorkspace": "", "RecentWorkspaces": [], "ReduceEffects": false, "BackdropPreference": "Auto", "DryRunDefault": false, "Window": { "Left":..., "Top":..., "Width":..., "Height":..., "Maximized": false } }`. +Use `System.Text.Json`, tolerate a missing or corrupt file (return defaults, never throw), keep at most 10 recent workspaces, take the folder as a constructor argument so tests use a temp folder. + +**Tests:** commands (can-execute, async running state, exception path), navigation, settings (missing file, corrupt file, recent list order and limit, round trip). + +### Phase 5 — `WpfUserInterface` and the dialog host + +**Create:** `Services/IDialogService.cs`, `Services/DialogService.cs`, `ViewModels/DialogHostViewModel.cs`, `ViewModels/Dialogs/ConfirmDialogViewModel.cs`, `ChoiceDialogViewModel.cs`, `AskDialogViewModel.cs`, the three dialog views (as `DataTemplate`s selected by view-model type), `Services/WpfUserInterface.cs` (skeleton in Appendix C), and the overlay in `ShellWindow` (a `Grid` with a dimmed layer and a centered glass card, visible when `DialogHost.Current != null`). + +**Behavior** +- Confirm: title, details in a monospace box (selectable, scrollable), buttons **Approve** (gold, default, Enter) and **Decline** (ghost, Esc). +- Choice: title, numbered options (`1 Label` with the description underneath in muted text), the default option highlighted, up/down arrows to move, **Enter** to confirm, **digit keys 1 to 9 pick immediately**, **Esc** cancels (returns `ChoiceResult(null, true)`). +- Ask: question, multiline text box, **Ctrl+Enter** to submit, Esc returns an empty string. +- One dialog at a time (queue). `CancelAll()` completes every pending dialog (Confirm → false, Choice → cancelled, Ask → empty). +- Every dialog fades in (150 ms). + +**Tests (with `ImmediateDispatcher`):** two concurrent `ConfirmAsync` calls are shown one after another; answering completes the task; `CancelAll` completes pending tasks; digit key selects the option; Esc cancels; `BeginWait` nesting shows/hides messages in the right order; `Say`/`ShowProgress` from a background thread end up in the feed. + +### Phase 6 — Agent page: feed, composer, running a turn + +**Create:** `ViewModels/AgentViewModel.cs`, `ViewModels/Feed/*` (item types: `UserRequestItem`, `StepItem`, `ModelMessageItem`, `CommandItem`, `DiffItem`, `ErrorItem`, `TurnSummaryItem`), `Services/EventPresenter.cs`, `Services/AgentRunner.cs`, `Views/AgentView.xaml`, feed item views. + +**EventPresenter** (pure, unit tested) maps `AgentEventType` to `{ Label, Symbol, Kind }`: + +| Event | Label | Symbol | +|---|---|---| +| SessionStarting | Preparing the session | ● | +| ConnectingToBrowser | Connecting to Chrome | ● | +| OpeningChat | Opening the chat | ● | +| UploadingAttachments | Uploading project snapshot | ▲ | +| SendingMessage | Sending your request | ▶ | +| WaitingForReply | Waiting for the model | ● (pulsing) | +| ValidatingReply | Checking the reply | ✓ | +| RequestingResend | Asking the model to fix its reply | ⚠ | +| ExecutingCommand | Running a command | ▶ | +| AskingUser | Waiting for you | ● | +| SendingResults | Sending results to the model | ▶ | +| RefreshingSnapshot | Updating the project snapshot | ● | +| TurnFinished | Finished | ✓ | + +For `ExecutingCommand` the `Detail` looks like `#1 RunCommand mkdir y`: parse it with a regex into number, kind, target; if it does not match, show the raw text. Do not crash on unexpected text. + +**AgentRunner** (the only place that calls the loop): creates the loop (verbose logger factory when the user turned diagnostics on), calls `RunAsync(workspace, request, ui, dryRun, ct)`, maps the result to a `TurnSummaryItem`, handles `OperationCanceledException` ("Cancelled") and any other exception (shows an `ErrorItem` with the message, never crashes the app). + +**AgentViewModel:** `RequestText`, `DryRun`, `IsRunning`, `SendCommand` (disabled when empty or running or no workspace), `StopCommand` (cancels the token and calls `DialogHost.CancelAll()`), `Feed` (`ObservableCollection`), `ChangedFiles`, `LastSummary`. Sending adds a `UserRequestItem`, clears the box, and scrolls the feed to the end (use a behavior or a small attached property, not business logic in code-behind). **Ctrl+Enter** sends. Feed items animate in (`SlideUpFadeIn`). The right-hand panel shows the changed files and the summary card; it collapses on narrow windows (< 1100 px). + +**Tests:** presenter table, command parsing (valid, invalid, empty), `AgentViewModel` with a **fake `IAgentLoop`** (it needs a small interface or a delegate `Func<...>` injected into `AgentRunner`): successful turn, error turn, cancelled turn, double send blocked. + +### Phase 7 — Diff viewer + +**Create:** `Services/LineDiff.cs` (Appendix D), `ViewModels/Feed/DiffItem.cs`, `Views/DiffView.xaml`. + +- Unified diff with line numbers: removed lines on a red-tinted background (`DiffRemove`), added lines on a green-tinted one (`DiffAdd`), unchanged lines dimmed, long unchanged runs collapsed to `… 42 unchanged lines`. Monospace font. Header shows the path and the kind (Create / Update / Edit / Delete) as a colored pill. +- `FileChange` with `OldContent == null` is a new file (all lines added); `NewContent == null` is a delete (all lines removed). Files above 3000 lines: show "File too large to diff (N lines)" plus counts. +- The diff card is collapsible (default: collapsed after the turn ends, expanded while it is pending confirmation). + +**Tests (the important ones):** identical text, empty to text, text to empty, one line changed, line added in the middle, CRLF versus LF (must not show every line as changed), a trailing newline difference, a 5000-line file (returns the "too large" result quickly). + +### Phase 8 — Workspace page and header + +**Create:** `ViewModels/WorkspaceViewModel.cs`, `Views/WorkspaceView.xaml`, header controls in `ShellWindow` / `AgentView`. + +- **Browse…** uses `Microsoft.Win32.OpenFolderDialog` (built into WPF on .NET 8+). Do not use WinForms. +- Recent workspaces as glass cards (name, path, last used); click to select; a remove (✕) button per card. +- Show capabilities from `IWorkspaceInspector` (git installed? is a repository? current branch? agent branch?). Read the inspector's interface and call it on a background task. Failure shows a friendly note, never an exception. +- The header on the Agent page shows the workspace path (trimmed in the middle with a tooltip) and a branch pill. +- A request can only be sent when a valid folder is selected. + +**Tests:** recent list handling, invalid path handling, capabilities mapping with a fake inspector. + +### Phase 9 — Doctor, undo, Chrome, settings, about + +- **Doctor page:** button "Run checks" calls `IChatDiagnostics` exactly like `DoctorCommand`. Show each `SelectorCheck` as a row with a status chip (Matched = Success color, NotFound = Danger, NotApplicableNow = muted) and the detail text; show the summary values (logged in, DeepThink/Search state, input accepts text). Runs in the background with a busy indicator and a Cancel button. +- **Undo:** a button in the Changes panel calls `IBackupService.UndoLatestAsync`, after a Confirm dialog ("This restores the latest backups. Changes made by commands cannot be undone."). Show the list of restored files. +- **Start Chrome:** a button that does what `keyless chrome` does (read `ChromeCommand.cs`); show the connection kind in the status bar. +- **Settings page:** show the loaded `keyless.json` options (read-only, grouped: Mode, Browser, Limits, Snapshot, Git, Commands) from `IConfigurationService.LoadAsync`; buttons **Open keyless.json** (`Process.Start` with `UseShellExecute = true`) and **Reload**. UI-only settings: Reduce effects, Backdrop (Auto/Mica/Acrylic/None), Dry run by default, Verbose diagnostics. +- **About page:** name, version (from the assembly), a short description, the "not affiliated with DeepSeek" notice, the terms-of-service/account-risk warning from the main README, a link-style button that opens the repository folder. + +**Tests:** doctor row mapping, settings grouping, undo flow with fake services (confirm declined = nothing called). + +### Phase 10 — Polish + +- Entrance animations for pages, hover and pressed animations for cards and buttons, a pulsing busy dot while `IsRunning`, smooth feed auto-scroll. All of it is skipped when `ReduceEffects` is on. +- Keyboard: **Ctrl+Enter** send, **Esc** stop the turn (if no dialog is open), **Ctrl+L** focus the composer, **Ctrl+,** open Settings, **F1** open About. +- **Persian/Arabic support in the composer:** `Infrastructure/TextDirection.cs` with a pure function `Detect(string)` that returns RightToLeft when the first strong letter is in the Arabic/Persian ranges (U+0600 to U+06FF, U+0750 to U+077F, U+FB50 to U+FDFF, U+FE70 to U+FEFF) and LeftToRight otherwise. Bind the composer `FlowDirection` and text alignment to it. Unit tests for English, Persian, mixed, empty, digits only. +- Remember window size, position and state (from `SettingsService`), restore them on start, and make sure the restored position is on a visible monitor. +- Global exception handling (Appendix E) writes a crash log to `%LOCALAPPDATA%\KeylessAgent\wpf-crash.log` and shows an error dialog instead of closing silently. +- Optional light theme: only if time remains. Tokens must be `DynamicResource` so a second dictionary can replace them. + +### Phase 11 — Packaging + +- `dotnet publish src/UI/ImanSoftware.KeylessAgent.Wpf -c Release -r win-x64 --self-contained false -o artifacts/wpf` must succeed. Check that the Playwright driver files (`.playwright` folder) are present in the output (Core depends on Microsoft.Playwright); if not, report it. +- Add `ApplicationIcon` (reuse the CLI `icon.png`; convert to `.ico` only if you can do it with a command that exists; otherwise skip the icon and report it). +- Add `src/UI/ImanSoftware.KeylessAgent.Wpf/publish-wpf.ps1` that runs the command above. Do not run `publish.ps1` of the CLI and do not push anything to NuGet. + +### Phase 12 — Documentation (once, last) + +Update `README.md` (new section "Desktop app (WPF)": features, how to build and run, requirements, screenshots placeholder, Windows 11 backdrop note, known limitations), add `docs/wpf-ui.md` (architecture, threading model, how to add a page, theme tokens, testing), update the repository layout section, apply and delete `DOCS-TODO.md`. Add a unit test that checks every theme token documented in `docs/wpf-ui.md` exists in `Colors.xaml`. + +--- + +## 7. Testing strategy + +- Project `ImanSoftware.KeylessAgent.Wpf.Tests` (`net10.0-windows`, `UseWPF` only if a test really needs a WPF type). Test **ViewModels, services, pure helpers** (`LineDiff`, `EventPresenter`, `BackdropPlanner`, `TextDirection`, `SettingsService`, dialog queue). Do not test pixels. +- Use fakes, not mocking libraries: `FakeAgentLoop`, `FakeDialogService`, `ImmediateDispatcher`, `FakeWorkspaceInspector`. +- Run `dotnet test` for the **whole solution** at the end of every phase to prove you did not break Core or the CLI. +- A test that touches the real Chrome, the network or the real `%APPDATA%` is not allowed (use temp folders). + +## 8. Definition of done and the user's manual check + +When all phases are done, tell the user to verify by eye (you cannot): + +1. The window opens with a Mica/Acrylic or gradient background and soft glowing orbs; panels look frosted. +2. Title-bar buttons work, the window resizes, maximizing does not cut the content. +3. Choosing a workspace, typing a request and pressing Send shows the live feed; Stop really stops. +4. A command from the model opens the Approve/Decline dialog; digit keys work in the choice dialog. +5. Diffs show red and green lines; CRLF files are not shown as fully changed. +6. Doctor shows green/red chips; Undo asks for confirmation. +7. Reduce effects removes orbs/animations; Persian text in the composer is right-to-left. +8. Closing the app leaves no stray Chrome or background process that the app started itself. + +## 9. How to report (use exactly this after every phase) + +```text +Phase N: done | blocked +Files created/changed: +Build: -> +Tests: -> +Not verified (you cannot see the UI): +Questions or risks: +``` + +## 10. Common mistakes to avoid + +- A `StaticResource` that is used before it is defined (merge order!) fails at runtime, not at compile time. Keep the order from Phase 2 and prefer `DynamicResource` in views. +- `x:Class` must match the namespace and class name of the code-behind exactly. A duplicate `x:Name` is a compile error. +- Do not set `AllowsTransparency="True"` on the main window. Do not put the real content inside the blurred layer (blur affects children). +- Collection changes must happen on the UI thread; use `IUiDispatcher.Post`. +- Do not hold a dialog open from a background thread without the queue. Always release the `SemaphoreSlim` in `finally`. +- If a build fails 3 times with the same error, stop and report the error text instead of trying random changes. + +--- + +## Appendix A — Backdrop helper (Windows 11 Mica/Acrylic) + +```csharp +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; + +namespace ImanSoftware.KeylessAgent.Wpf.Infrastructure; + +/// Applies the Windows 11 system backdrop and dark title bar to a window. +internal static class WindowBackdrop +{ + private const int DwmwaUseImmersiveDarkMode = 20; + private const int DwmwaWindowCornerPreference = 33; + private const int DwmwaSystemBackdropType = 38; // Windows 11 22H2 (build 22621) or newer + private const int RoundCorners = 2; + + [DllImport("dwmapi.dll")] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size); + + /// kind: 1 = none, 2 = Mica, 3 = Acrylic. Returns false when it could not be applied. + public static bool TryApply(Window window, int kind) + { + try + { + var handle = new WindowInteropHelper(window).Handle; + if (handle == IntPtr.Zero) return false; + + // Let the DWM draw behind the WPF content. + var source = HwndSource.FromHwnd(handle); + if (source?.CompositionTarget is not null) + source.CompositionTarget.BackgroundColor = Colors.Transparent; + + var dark = 1; + DwmSetWindowAttribute(handle, DwmwaUseImmersiveDarkMode, ref dark, sizeof(int)); + var corners = RoundCorners; + DwmSetWindowAttribute(handle, DwmwaWindowCornerPreference, ref corners, sizeof(int)); + var backdrop = kind; + return DwmSetWindowAttribute(handle, DwmwaSystemBackdropType, ref backdrop, sizeof(int)) == 0; + } + catch (Exception) + { + return false; // the fallback gradient will be shown + } + } +} +``` + +Use it in `ShellWindow.OnSourceInitialized`: ask `BackdropPlanner.Decide(Environment.OSVersion.Version.Build, settings.BackdropPreference, settings.ReduceEffects)`, call `TryApply` when the plan says Mica/Acrylic, and set `FallbackBackground.Visibility` to `Visible` when the plan says fallback **or** `TryApply` returned false. These numeric values were written from memory: if the backdrop does not appear on Windows 11, report it instead of guessing. + +## Appendix B — Project file + +```xml + + + WinExe + net10.0-windows + true + ImanSoftware.KeylessAgent.Wpf + ImanSoftware.KeylessAgent.Wpf + + $(NoWarn);CA1812;CA1515 + + + + + + + + +``` + +The test project uses `net10.0-windows` and references the Wpf project. Copy its package references from the existing Core test project (no versions). + +## Appendix C — `WpfUserInterface` skeleton + +```csharp +internal sealed class WpfUserInterface : IAgentUserInterface +{ + private readonly IUiDispatcher _ui; + private readonly IDialogService _dialogs; + private readonly ActivityFeed _feed; // owns the ObservableCollection of feed items + private readonly BusyIndicator _busy; // stack of wait messages + + public WpfUserInterface(IUiDispatcher ui, IDialogService dialogs, ActivityFeed feed, BusyIndicator busy) + => (_ui, _dialogs, _feed, _busy) = (ui, dialogs, feed, busy); + + public void Say(string message) => _ui.Post(() => _feed.AddModelMessage(message)); + public void ShowProgress(AgentProgressEvent e) => _ui.Post(() => _feed.AddStep(EventPresenter.Present(e))); + public void ShowDiff(FileChange change) => _ui.Post(() => _feed.AddDiff(change)); + + public Task ConfirmAsync(ConfirmRequest request) => _dialogs.ConfirmAsync(request); + public Task AskAsync(string question) => _dialogs.AskAsync(question); + public Task ChooseAsync(ChoiceRequest request, CancellationToken ct = default) + => _dialogs.ChooseAsync(request, ct); + + public IDisposable BeginWait(string message) + { + var token = _busy.Push(message); // thread-safe, posts to the UI thread inside + return new ActionDisposable(() => _busy.Pop(token)); // safe to dispose twice + } +} +``` + +`DialogService` outline: one `SemaphoreSlim(1,1)`; `ConfirmAsync` does `await _gate.WaitAsync()`, creates a `TaskCompletionSource`, posts `Host.Show(vm)` to the UI thread, `await tcs.Task`, hides the dialog, releases the gate in `finally`. `ChooseAsync` also registers `ct.Register(() => tcs.TrySetResult(ChoiceResult.CancelledMenu()))`. + +## Appendix D — Line diff (simple and safe) + +```csharp +internal enum DiffLineKind { Unchanged, Added, Removed } +internal sealed record DiffLine(DiffLineKind Kind, string Text, int? OldNumber, int? NewNumber); +internal sealed record DiffResult(IReadOnlyList Lines, int Added, int Removed, bool TooLarge); + +internal static class LineDiff +{ + public const int MaxLines = 3000; + + /// Compares two texts line by line. CRLF and LF are treated as the same line ending. + public static DiffResult Compare(string? oldText, string? newText) { /* see steps below */ } +} +``` + +Steps: (1) normalize `\r\n` and lone `\r` to `\n`, split on `\n`; a final empty element caused by a trailing newline is dropped on both sides; `null` means an empty file. (2) if either side has more than `MaxLines` lines return `TooLarge = true` with the counts. (3) trim the common prefix and common suffix. (4) on the remaining middle part compute the longest common subsequence with a classic dynamic-programming table (the middle part is small after trimming; cap the table at 3000 x 3000 and fall back to "all removed then all added" above that). (5) walk the table to produce `Unchanged/Added/Removed` lines with old/new line numbers. (6) count added and removed lines. + +## Appendix E — Crash handling in `App.xaml.cs` + +Subscribe to `DispatcherUnhandledException`, `AppDomain.CurrentDomain.UnhandledException` and `TaskScheduler.UnobservedTaskException`. In each handler: write the exception (`ToString()`) with a timestamp to `%LOCALAPPDATA%\KeylessAgent\wpf-crash.log` (never throw from the logger), show a short error through the dialog host when it exists, set `e.Handled = true` for the dispatcher one, and keep running unless the error is fatal. diff --git a/docs/agentscript-spec.md b/docs/agentscript-spec.md index ea70f85..a366407 100644 --- a/docs/agentscript-spec.md +++ b/docs/agentscript-spec.md @@ -13,7 +13,7 @@ section := "<<<" NAME [ ":" nonce ] newline content-line* NAME [ ":" nonce ] ">>>" newline ; command := "say" | "newfile" | "updatefile" | "editfile" | "deletefile" - | "readfile" | "runcommand" | "ask" | "commit" | "done" ; + | "readfile" | "mkdir" | "runcommand" | "ask" | "commit" | "done" ; NAME := "MSG" | "CONTENT" | "FIND" | "REPLACE" | "CMD" ; ``` @@ -38,6 +38,7 @@ Rules: | `editfile` | path | `FIND`, `REPLACE` | Replaces the single occurrence of FIND. Fails unless FIND matches exactly once. BOM is preserved. Matching and counting run on LF-normalized text, and the written file keeps its original (dominant) line ending — a CRLF file stays 100% CRLF. | | `deletefile` | path | — | Backs up, then deletes (confirmed in Ask mode). | | `readfile` | path | — | Returns the file content as that command's result. | +| `mkdir` | path | — | Creates the folder (and parents); idempotent, never confirmed | | `runcommand` | — | `CMD` | Runs one command line. Every segment split on `&&`, `||`, `;`, `|` is checked against the policy. Optional argument `on_error=continue` or `on_error=stop` overrides the batch failure mode for that command. | | `ask` | — | `MSG` | Asks the user; the answer becomes that command's result. | | `commit` | — | `MSG` | `git add -A`, commit, `git push -u origin `. Push failures are reported to the model. | @@ -100,13 +101,36 @@ Use `@@runcommand on_error=continue` to override the mode for that command only. | Limit | Default | Behavior when exceeded | | --- | --- | --- | -| `MaxCommandsPerResponse` | 5 | First N non-`say` commands execute; the rest are reported as `skipped` and resent by the model | +| `MaxCommandsPerResponse` | 5 | First N non-`say` commands execute; the rest are reported as `skipped` and resent by the model. The EFFECTIVE limit is min(Agent.Pace limit, this value) — see Pacing below | | `MaxResponseChars` | 60000 | Reply rejected (`KA_LIMIT_RESPONSE_CHARS`); model told to split | | `MaxFileContentChars` | 40000 | Reply rejected (`KA_LIMIT_FILE_CONTENT_CHARS`) | | `MaxCommandOutputChars` | 8000 | Result output capped (head + tail kept, cut marked) | | `MaxFormatRetries` | 3 | After N invalid replies the turn stops and the user sees the raw last reply | | `MaxIterationsPerTurn` | 25 | Turn stops with a stop reason | +## Pacing (Agent.Pace) + +`Agent.Pace` (keyless.json, or `--pace` on `run`/interactive) caps how many NON-`say` commands one response may carry: **Step** = 1, **Balanced** = 3 (default), **Batch** = `Limits.MaxCommandsPerResponse`. The effective limit is `min(Pace limit, Limits.MaxCommandsPerResponse)` and is injected into the system prompt. + +When a response carries more commands than the effective limit, the first N execute and the rest are reported as skipped with the reason: + +```text +pace=: only N command(s) per response; send the next one after reading this result +``` + +(The generic "batch limit … resend them next" note is used only in Batch mode.) In Step mode `MaxIterationsPerTurn` is raised to at least 60 for the turn, and the system prompt/reminder carry the Step rule: one command, then `@@next`, verify the result before continuing. + +## Verified line + +Successful `newfile`, `updatefile`, `editfile`, `movefile` and `mkdir` results carry one compact line the model can trust without re-reading the file: + +```text +verified: path=

bytes= lines= sha=<8 hex> first="" last="" +``` + +`mkdir` results carry `verified: dir exists`. Newlines inside the snippets are shown as ` +` escapes; the line is capped at 300 characters and never contains the full file content. CRLF files count both bytes per line ending; lines are counted after CRLF/LF normalization. + ## @@done ends the turn A successful `@@done` finishes the turn immediately: commands placed AFTER it in the same reply are reported as `skipped` with the reason `after @@done` and are never executed. `@@say` blocks after `@@done` still reach the user. @@ -209,8 +233,12 @@ Every failure the agent reports carries a stable code. | `KA_BROWSER_CONNECTION_FAILED` | cannot attach to / launch Chrome | | `KA_SELECTOR_NOT_FOUND` | a required selector did not match | | `KA_REPLY_TIMEOUT` | no complete reply within the timeout | +| `KA_REPLY_TRUNCATED` | reply stream stayed truncated (AgentScript without @@eof) longer than Browser.TruncationBudgetSeconds in one wait | | `KA_SEND_NOT_CONFIRMED` | the message could not be sent (not confirmed in the DOM, retry failed too) | | `KA_ATTACHMENT_UPLOAD_FAILED` | file upload failed (falls back to pasting) | +| `KA_NETWORK_OFFLINE` | the machine is offline — nothing was uploaded or sent; the chat session stays valid, resend the same request | +| `KA_TOOL_MISSING` | a runcommand tool is listed as Missing in the environment — the process is never started; do not retry it | +| `KA_COMPOSER_NOT_CLEAN` | the composer still holds stale text or attachment chips after the reset attempt (run `keyless doctor` to fix the AttachmentRemove/AttachmentFailed selectors) | | `KA_CHAT_EXHAUSTED` | chat hit its length limit | ### Configuration / snapshot / internal diff --git a/docs/architecture.md b/docs/architecture.md index b83fc06..8e71412 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,6 +37,7 @@ KeylessAgent mirrors the layout of [DepLens](https://github.com/ImanEstiri/DepLe | `ISnapshotService` | `Snapshot/SnapshotService` + `SnapshotFilter` + `WorkspaceAnalyzer` | generates the tree/code-dump snapshots natively in C# (no workspace scripts needed); filters/chunks attachments | | `IResultFormatter` | `Agent/ResultFormatter` | `[KEYLESSAGENT RESULTS]` messages | | `IChatProvider` / `IChatSession` | `Browser/PlaywrightChatProvider` / `PlaywrightChatSession`, `Browser/FakeChatProvider` | the browser behind an interface; fake for tests/Playground | +| `IEnvironmentTools` / `EnvironmentProbe` | probes git/dotnet/node/npm/python (24 h cache, per-tool 1 h freshness, invalidation); powers the fail-fast tool rule and the UIs' Tools line | | `ISessionStore` | `Agent/SessionStore` | `.agent/session.json` (chat URL, branch, turn) | | `IConfigurationService` | `Configuration/ConfigurationService` | tolerant `keyless.json` load/validate/create | | `IAgentLoop` | `Agent/AgentLoop` | one call = one turn; orchestrates everything | diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 295ad68..35db7ff 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -228,3 +228,11 @@ Built from a DOM sample, verified only by unit tests so far. Before relying on i 5. Toggle labels (`DeepThink`/`Search` texts) and `aria-pressed` values. Record the results of this checklist in this file after testing. + +## Offline, composer reset and verified results + +Before every message the composer is reset: leftover text is cleared (select-all + delete, verified), stale attachment chips are removed (failed chips first, via the `AttachmentRemove` / `AttachmentFailed` selector keys in `selectors.json`, reload fallback), and the result is verified — otherwise the turn fails with `KA_COMPOSER_NOT_CLEAN`. A `StartSession` performs the same reset once. + +Every send is gated on connectivity (`navigator.onLine` plus a no-store probe of the chat origin, cached 2 s). Offline before a send → `KA_NETWORK_OFFLINE` and nothing is uploaded or pasted (optionally wait `Browser.WaitForNetworkSeconds`); a drop during upload/confirmation/reply wait stops the wait at once and reports the same code. A chip in the failed state right after `SetInputFiles` fails the upload immediately (`KA_ATTACHMENT_UPLOAD_FAILED`, composer cleaned, paste fallback only while online). + +Successful file commands carry a `verified:` line in the results message (path, bytes, lines, SHA-8, first/last 60 characters) — see [agentscript-spec.md](agentscript-spec.md#verified-line). The `AttachmentRemove`/`AttachmentFailed` selector keys are UNVERIFIED against the live UI; `keyless doctor` prints the closest chip candidate so they can be fixed from facts. \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md index 82c6695..3c65cf2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,4 +1,4 @@ -# Configuration + Fires after a quarter of **TruncationBudgetSeconds** has elapsed, not after a fixed attempt count.# Configuration KeylessAgent reads `keyless.json` from the workspace root. The first run writes the full default file so you can edit it. Parsing is **tolerant**: comments (`//`), case-insensitive keys, trailing commas, and string enums (`"Ask"`, `"AutoSafe"`, …) all work. Invalid JSON or out-of-range values fail with a precise message instead of an exception. @@ -7,6 +7,10 @@ KeylessAgent reads `keyless.json` from the workspace root. The first run writes // How much confirmation the agent asks for. "Mode": "Ask", // Ask | AutoSafe | Auto + // Schema version of this file; absent = v1. The loader migrates stale files to + // CurrentSchemaVersion (backing up the original as keyless.json.v.bak). + "SchemaVersion": 2, + "Browser": { "ChromeDebugPort": 9222, // CDP port; ignored by Chrome's DEFAULT profile since Chrome 136 "UserDataDir": "", // dedicated profile; empty = %LOCALAPPDATA%\KeylessAgent\chrome-profile @@ -18,17 +22,20 @@ KeylessAgent reads `keyless.json` from the workspace root. The first run writes "EnableDeepThink": true, // DeepThink toggle before the first message "EnableSearch": true, // Search toggle before the first message "SelectorsFile": "selectors.json", - "ReplyTimeoutSeconds": 300, + "ReplyTimeoutSeconds": 180, "ReplySource": "Auto", // Auto (copy button + validation + DOM fallback) | Copy | Dom - "WindowMode": "Minimized", // Minimized | Offscreen | Normal (auto-launched Chrome only) + "WindowMode": "TinyCorner", // TinyCorner (default) | EdgePeek | Offscreen | Minimized | Normal (auto-launched Chrome only) "MinimizeAfterLogin": true, // minimize the window after login completes + "AutoFocusOnTruncation": false, // un-minimize once when a throttled stream was rejected 5x (default: stay minimized) + "TruncationBudgetSeconds": 20, // wall-clock budget for a throttled stream before the wait fails (KA_REPLY_TRUNCATED) "LoginProbeSeconds": 15, // positive-first probe: wait for InputBox OR login indicator "MaxTurnsPerChat": 20, // turns per chat before a new chat starts automatically "HumanDelay": false, // random delays between browser actions (default: event-driven only) - "SendConfirmSeconds": 15, // wait for proof the message was sent before retrying once - "UploadWaitSeconds": 60, // wait for attachment chips + enabled send before pasting - "MinDelayMs": 0, // random delay between actions (default: event-driven only) - "MaxDelayMs": 150, + "SendConfirmSeconds": 8, // wait for proof the message was sent before retrying once + "UploadWaitSeconds": 15, // wait for attachment chips + enabled send before pasting + "WaitForNetworkSeconds": 0, // when offline before a send: poll up to N s for the connection (0 = fail at once) + "MinDelayMs": 400, // random delay between actions (default: event-driven only) + "MaxDelayMs": 1500, "MaxMessagesPerMinute": 6, // local rate limiter "Debug": false // screenshots on errors }, @@ -43,12 +50,15 @@ KeylessAgent reads `keyless.json` from the workspace root. The first run writes }, "Snapshot": { - "Mode": "Full", // Full | TreeAndChanged + "Mode": "TreeAndChanged", // TreeAndChanged (default) | Full + "MaxTotalAttachmentChars": 400000, // total ceiling for ONE turn's attachments (tree + dump) + "MaxDumpedFileChars": 50000, // per-file cap in the attached dump (placeholder beyond it) "AttachOnFeedback": false, // also attach the snapshot to result messages "RefreshAfterEachBatch": false, "Filter": { "ExcludeTreePatterns": [ "bin", "obj", ".git", "node_modules", ".agent", "artifacts" ], "ExcludeDumpHeadings": [ "tree-view-result", "code-dump-result" ], + "ExcludeFilePatterns": [ "*.min.js", "*.min.css", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "*.designer.cs", "*.g.cs", "*.g.i.cs", "*ModelSnapshot.cs" ], "SensitiveFilePatterns": [ ".env", ".env.*", "*.pem", "*.key", "*.pfx", "*.p12", "id_rsa*", "*.keystore", "secrets.json", "*secret*", "*password*", "*credential*", "appsettings.Production.json", "appsettings.*.Production.json" ], "MaskSensitiveLines": true } @@ -60,11 +70,15 @@ KeylessAgent reads `keyless.json` from the workspace root. The first run writes "ProtectedBranchPatterns": [ "main", "master", "release*" ] }, + "Agent": { + "Pace": "Balanced" // Step = 1 command per response, Balanced = 3, Batch = Limits.MaxCommandsPerResponse + }, + "Commands": { "Allowlist": [ "dotnet", "git", "npm", "node", "python" ], "AutoApprove": [ "ReadOnly", "Build" ], "BatchFailureMode": "StopOnMutationFailure", - "TimeoutSeconds": 300 + "TimeoutSeconds": 180 } } ``` @@ -107,7 +121,8 @@ See [agentscript-spec.md](agentscript-spec.md#limits-enforced-injected-into-the- ### Browser window -- **WindowMode**: `Minimized` (default), `Offscreen`, or `Normal`. Affects auto-launched Chrome only. When minimized, throttling flags (`--disable-renderer-backgrounding`, `--disable-background-timer-throttling`, `--disable-backgrounding-occluded-windows`) prevent slow-downs. +- **WindowMode**: `TinyCorner` (default), `EdgePeek`, `Offscreen`, `Minimized` (backward-compatibility alias of offscreen), or `Normal`. Affects auto-launched Chrome only and the hide geometry. The window must be visible-on-screen (at least 80x80 pixels) to prevent Chrome's occlusion throttling: fully offscreen positions do NOT work on Chrome 130+ / Windows 11 24H2 even with `--disable-features=CalculateNativeWinOcclusion` — the renderer throttles and truncates the DeepSeek stream (turn-17/18 evidence). TinyCorner keeps an 80x80 square in the bottom-right corner (primary screen bounds, not hardcoded); EdgePeek uses a 10x10 sliver in the corner (may still be throttled if Chrome's heuristic rejects it). Anti-throttling flags (`--disable-renderer-backgrounding`, `--disable-background-timer-throttling`, `--disable-backgrounding-occluded-windows`, `--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling`) are always passed; verify the running Chrome with `keyless diagnostics chrome`. +- **TruncationBudgetSeconds** (default 20): wall-clock budget for a throttled reply stream - the wait keeps polling (spaced by the normal poll delay) while the stream may still resume, and fails with KA_REPLY_TRUNCATED only when the time since the FIRST truncation detection exceeds this. The reply-truncated turn event carries ElapsedSeconds so the logs show real timing. - **MinimizeAfterLogin**: minimize the window again after login completes (default true). The window is restored ONLY for login/captcha. ### Snapshot — sensitive content never reaches the chat @@ -122,6 +137,11 @@ See [agentscript-spec.md](agentscript-spec.md#limits-enforced-injected-into-the- - The snapshots are **generated natively in C#** — no `.bat` files need to exist in the workspace. `tree-view-result.txt` and `code-dump-result.md` are written into the workspace root (and excluded from git via `.git/info/exclude`). - Filtering applies to the **attached copy only** — `tree-view-result.txt` and `code-dump-result.md` on disk are never modified. - Oversized dumps are split at `### heading` boundaries into `code-dump-result-part1.md`, `-part2.md`, … and uploaded as consecutive files. +- **Snapshot.Mode** is `TreeAndChanged` by default: the tree plus the files changed since the previous refresh (the diff is stashed BEFORE the baseline is overwritten, so it really contains the agent's changes). `Full` attaches everything, for anyone who explicitly wants it. +- **First turn / no usable baseline / empty diff**: the attachment falls back to the tree plus a BUDGET-LIMITED dump — never an unlimited full dump. The next turn has a real baseline and diffs normally. +- **Snapshot.MaxTotalAttachmentChars** (default 400,000 ≈ chars/4 tokens) caps EVERYTHING attached to one turn. When exceeded: the tree stays complete, the dump keeps only files referenced by the turn's request text, and every other file becomes a per-folder note (`[omitted: N files under …/ (N KB) — use the file-read tool …]`). A `snapshot-budget-exceeded` turn event records the original total, the ceiling and the omissions. +- **Snapshot.MaxDumpedFileChars** (default 50,000) caps each file's fenced content in the dump: oversized files keep their `###` heading plus `[file too large to attach: N KB — use the file-read tool …]`. +- **Snapshot.Filter.ExcludeFilePatterns** (case-insensitive `*` wildcards against the file name) drop generated/vendored files from the attached dump entirely: `*.min.js`, `*.min.css`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `*.designer.cs`, `*.g.cs`, `*.g.i.cs`, `*ModelSnapshot.cs` by default. Disk copies are never touched. ### Git diff --git a/docs/wpf-ui.md b/docs/wpf-ui.md new file mode 100644 index 0000000..4bdfee5 --- /dev/null +++ b/docs/wpf-ui.md @@ -0,0 +1,213 @@ +# WPF desktop UI + +The desktop app lives in `src/UI/ImanSoftware.KeylessAgent.Wpf` (net10.0-windows, WPF, MVVM). +It hosts the same Core agent loop as the CLI — Core knows nothing about WPF; the UI implements +`IAgentUserInterface` and calls `IAgentLoop` through one seam. This document describes what is +actually implemented; the [verification status](#verification-status) section states exactly +what was verified automatically and what still needs a human eye. + +## Projects + +| Project | Purpose | +|---|---| +| `src/UI/ImanSoftware.KeylessAgent.Wpf` | The app (`WinExe`, `UseWPF`). References only `ImanSoftware.KeylessAgent.Core`; `InternalsVisibleTo` exposes internals to the test project. | +| `src/tests/ImanSoftware.KeylessAgent.Wpf.Tests` | xunit + FluentAssertions tests on net10.0-windows. Uses hand-written fakes (no mocking library). | + +Both projects are part of `KeylessAgent.slnx`. + +## Architecture + +```text +ImanSoftware.KeylessAgent.Wpf/ +├── Mvvm/ # ObservableObject, RelayCommand, RelayCommand, AsyncRelayCommand +├── ViewModels/ # one view model per page + DialogHostViewModel + ActivityFeed/BusyIndicator +│ ├── Feed/ # IFeedItem records (UserRequest, Step, Command, Diff, Error, TurnSummary, …) +│ └── Dialogs/ # Confirm/Choice/Ask/Error dialog view models +├── Services/ # IDialogService/DialogService, DialogHost, WpfUserInterface, AgentRunner, +│ # AgentLoopFactory, EventPresenter, LineDiff, DoctorRowMapper, SettingsGroupMapper +├── Infrastructure/ # IUiDispatcher/WpfDispatcher, SettingsService/WpfSettings, BackdropPlanner, +│ # WindowBackdrop, TextDirection, WindowPlacement, converters, behaviors +├── Views/ # ShellWindow, one view per page, dialog views, DiffView +└── Themes/ # Colors, Brushes, Typography, Glass, Controls, Controls.Inputs, + # Dialogs, Pages, FeedTemplates, Converters, Animations (merged in this order) +``` + +`ShellWindow` is the composition root: its constructor creates the settings service, the dialog +service (`DialogHost.Initialize`), the `AgentRunner` (with `AgentLoopFactory` as the only loop +factory), every page view model, and a `ShellViewModel` whose page factory maps a `PageKey` +(Agent, Workspace, Doctor, Settings, About) to its view model. Pages render through +`DataTemplate`s selected by view-model type (`Themes/Pages.xaml`); the sidebar binds to the +shell's nav items. + +## Threading model + +Core calls `IAgentUserInterface` from any thread. The WPF side guarantees UI-thread safety: + +- `Say`/`ShowProgress`/`ShowDiff` are posted to the UI thread through `IUiDispatcher` + (`WpfDispatcher` in the app, an inline fake in tests) before touching the + `ObservableCollection` in `ActivityFeed`. +- `ConfirmAsync`/`AskAsync`/`ChooseAsync` go through `DialogService`: a `SemaphoreSlim(1,1)` + serializes dialogs, each request creates its `TaskCompletionSource` **before** acquiring the + gate (so `CancelAll` also completes queued requests), and all sources use + `TaskCreationOptions.RunContinuationsAsynchronously` to avoid deadlocks. The gate is always + released in `finally`; the overlay is always hidden afterwards. +- Stopping a turn cancels the run's `CancellationTokenSource` and calls `DialogHost.CancelAll()` + (confirm → `false`, choice → cancelled, ask → empty). +- `BeginWait` pushes onto the `BusyIndicator` nested stack (newest message wins, double dispose + is safe); its change notifications are marshalled to the UI thread. + +## How to add a page + +1. Add a value to `ViewModels/PageKey`. +2. Add a `NavigationItemViewModel` entry (title + glyph) to `ShellViewModel`. +3. Create the view model in `ViewModels` and the view in `Views`. +4. Add a `DataTemplate` mapping the view model to the view in `Themes/Pages.xaml`. +5. Register the page in the factory lambda in `ShellWindow`'s constructor. + +## How to add a feed item + +Add an `IFeedItem` record in `ViewModels/Feed/FeedItems.cs` (or `DiffItem.cs`), produce it from +`ActivityFeed` (or the service that owns the event), and give it a `DataTemplate` in +`Themes/FeedTemplates.xaml`. + +## Dialogs + +One dialog at a time; further requests queue. Keyboard: **Enter** approves a confirmation, +**Esc** declines/cancels; choice menus move with **↑/↓**, confirm with **Enter**, pick +immediately with **1–9**, cancel with **Esc**; ask dialogs submit with **Ctrl+Enter** and cancel +with **Esc** (empty answer); the error dialog closes with **Enter/Esc**. `DialogHost.CancelAll()` +completes every pending dialog whenever the user stops a turn. + +## Keyboard shortcuts + +| Keys | Action | +|---|---| +| `Ctrl+Enter` | Send the composer text / submit an ask dialog | +| `Esc` | Stop the running turn (only when no dialog is open) | +| `Ctrl+L` | Focus the composer (Agent page) | +| `Ctrl+,` | Open the Settings page | +| `F1` | Open the About page | + +## Theme tokens + +All colors come from `Themes/Colors.xaml`, mapped to brushes in `Themes/Brushes.xaml`. The unit +test `ThemeTokenTests.DocumentedThemeTokens_ExistInColorsXaml` parses this section — one token +per bullet — and asserts each key exists: + +- `BgDeep` — deepest tone of the fallback background gradient. +- `BgMid` — lighter tone of the fallback background gradient. +- `GlassFill` — translucent card fill. +- `GlassFillHover` — translucent fill on hover. +- `GlassFillStrong` — stronger translucent fill (dialogs, active rows, composer cards). +- `GlassBorder` — 1 px hairline for glass surfaces. +- `TextPrimary` — primary text. +- `TextSecondary` — secondary text. +- `TextMuted` — muted captions and hints. +- `Gold` — champagne accent (primary buttons, active navigation, highlights). +- `GoldHover` — gold on hover. +- `GoldPressed` — gold when pressed. +- `Violet` — midnight accent (backdrop orb). +- `Success` — healthy states. +- `Warning` — warnings and notes. +- `Danger` — destructive actions and failures. +- `Info` — informational highlights. +- `DiffAdd` — green tint for added lines. +- `DiffRemove` — red tint for removed lines. + +Dictionaries merge in `App.xaml` in this order: Colors, Brushes, Typography, Converters, Glass, +Controls, Controls.Inputs, Dialogs, Pages, FeedTemplates, Animations. Views reference resources +with `DynamicResource` so the order only matters for StaticResource-based styles. +`Views/StyleGalleryWindow.xaml` renders every style once and opens from the About page in +debug builds only. + +**Composer pace toggle:** the agent composer has a "Step by step" switch (`AgentViewModel.StepByStep`) that maps to `AgentPace.Step` when on and `AgentPace.Balanced` when off, passed as the turn's pace override. + +**TextMuted contrast:** `TextMuted` (#8F98B5) was lightened from #6F7898 so it passes WCAG AA on the glass surfaces: 6.0:1 on regular glass (GlassFill over BgDeep ≈ #171A24) and 4.6:1 on the strongest surface (GlassFillStrong ≈ #2D2F39), up from 3.96:1. Cards are spaced 8 px apart, all buttons are ≥ 32 px high with visible gold focus rings (including the toggle, ComboBox and ListBoxItem), the feed and changes panel have empty states ("Type a request to start" / "No changes yet"), and the title-bar icon buttons carry tooltips. + +### Backdrop + +`Infrastructure/BackdropPlanner` is a pure function of (OS build, preference, reduce effects): +Windows 11 22H2 (build 22621+) gets the requested system backdrop (Auto/Mica → Mica, +Acrylic → Acrylic, None → none); older builds and failures fall back to the app-drawn gradient +with blurred orbs (orbs hidden when the backdrop applies). The backdrop is applied in +`ShellWindow.OnSourceInitialized` and never throws. + +### Reduce effects + +The Settings page toggle persists `ReduceEffects` in `wpf-settings.json`. When set, the shell +removes the `Animations` resource dictionary (so `FadeIn`, `SlideUpFadeIn` and `PulseDot` styles +no-op) and the backdrop planner switches to the plain gradient without orbs. + +### Text direction + +`Infrastructure/TextDirection.Detect` returns RightToLeft when the first strong letter of the +composer text is in the Arabic/Persian ranges (U+0600–06FF, U+0750–077F, U+FB50–FDFF, +U+FE70–FEFF); digits, spaces and punctuation are neutral. Two converters bind it to the +composer's `FlowDirection`/`TextAlignment` — only the composer flips, the rest of the UI stays +left-to-right. + +## Window persistence + +`%APPDATA%\KeylessAgent\wpf-settings.json` stores `LastWorkspace`, `RecentWorkspaces` (max 10, +newest first), `ReduceEffects`, `BackdropPreference`, `DryRunDefault` and a `Window` block +(`Left`, `Top`, `Width`, `Height`, `Maximized`). On close the shell saves its bounds (restored +bounds when maximized); on start `Infrastructure/WindowPlacement` validates them — sizes under +200 DIP are ignored, and the position is clamped so at least a 100 DIP strip stays visible on +the virtual screen. A missing or corrupt file yields defaults. + +## Crash log + +Per guide Appendix E, `App.xaml.cs` handles `DispatcherUnhandledException`, +`AppDomain.UnhandledException` and `TaskScheduler.UnobservedTaskException`: each exception is +appended (timestamped, never throwing) to `%LOCALAPPDATA%\KeylessAgent\wpf-crash.log` and a +short error dialog is shown. The app keeps running. + +## Testing + +`dotnet test src/tests/ImanSoftware.KeylessAgent.Wpf.Tests` — 201 tests at the time of writing. +The suite avoids launching a WPF `Application`: + +- View models and services run against the `ImmediateDispatcher` fake and fake Core services + (`FakeDialogService`, `FakeBackupService`, `FakeConfigurationService`, `FakeBrowserConnector`) + plus temporary directories — never the real `%APPDATA%` and never Chrome. +- XAML dictionaries are asserted as text (theme tokens, documented tokens). +- Pure logic (`LineDiff`, `EventPresenter`, `DoctorRowMapper`, `SettingsGroupMapper`, + `TextDirection`, `WindowPlacement`, `BackdropPlanner`) is tested directly. +- Internals (e.g. `LineDiff`, `DiffItem`) are reachable via `InternalsVisibleTo`. + +## Publishing + +`powershell -File src\UI\ImanSoftware.KeylessAgent.Wpf\publish-wpf.ps1` runs the framework- +dependent publish (`-c Release -r win-x64 --self-contained false`) into `artifacts\wpf`, +verifies the executable, the Playwright driver (`.playwright` folder and +`Microsoft.Playwright.dll`) and the presence of the .NET 10 Desktop Runtime, and exits non-zero +on any failure. The runtime machine needs the **.NET 10 Desktop Runtime**; browser automation +needs the dedicated Chrome profile (see the CLI docs — the desktop app's "Start Chrome" button +does exactly what `keyless chrome` does). The application icon is intentionally not set yet: +`icon.png` exists but no PNG→ICO conversion tool was available. + +## Common mistakes + +- StaticResource needs the target defined above it — in the same file, or in a dictionary that + App.xaml merges earlier. `BeginStoryboard.Storyboard` cannot use DynamicResource, so every + animation Storyboard must be declared before the Style that starts it + (Themes/Animations.xaml). `ThemeOrderTests` walks all `Themes/*.xaml` dictionaries in merge + order and fails on any forward reference. +- **Never use DynamicResource inside the theme dictionaries themselves.** A DynamicResource + setter creates a `ResourceReferenceExpression`; sealing a freshly created deferred resource + invalidates the expressions it contains, which re-fetches (and re-creates) other deferred + resources — the re-entrant cascade overflows the stack in `FindResourceInternal` at first + window show (observed as `StackOverflowException` with the + `SealValue → AddInheritanceContext → InvalidateExpressionValue → GetValue → CreateObject` + cycle repeating ~1200 times). Theme dictionaries must be fully resolvable with + StaticResource — enforced by `ThemeOrderTests.ThemeDictionaries_ContainNoDynamicResourceReferences`. + Views keep DynamicResource (they resolve against cached entries and stay re-themeable). + +## Verification status + +- **Automated (verified):** whole-solution build with 0 errors (0 warnings in the WPF projects); + 201 WPF tests + 574 Core/CLI tests green; the publish command and `publish-wpf.ps1` executed + successfully with the Playwright driver present in the output. +- **Not verified (needs a human):** the app was never launched during development. Visual + appearance, layout, animations, the Windows 11 backdrop and the folder/file dialogs still + require manual checks on a real desktop. diff --git a/publish.ps1 b/publish.ps1 index 6585d82..94d05cc 100644 --- a/publish.ps1 +++ b/publish.ps1 @@ -177,8 +177,9 @@ function Invoke-ProcessWithTimeout { # =================================================================== try { - # KeylessAgent nests projects under src\Core, src\UI, src\Playground, ... so we - # locate the .csproj by name instead of assuming a fixed path. + # KeylessAgent nests projects under src\Core, src\UI, ... (like DepLens, unlike + # Platform's flat src\), so we locate the .csproj by name instead of + # assuming a fixed path. Write-Step "looking for $ProjectName.csproj under $srcRoot ..." $csprojFile = Get-ChildItem -Path $srcRoot -Filter "$ProjectName.csproj" -Recurse -ErrorAction SilentlyContinue | @@ -192,6 +193,16 @@ try { $csprojPath = $csprojFile.FullName Write-Step "found project at $projectPath" + # ===== API Key ===== + # Recommendation: do NOT hardcode the key in this file. Set it once, outside of git: + # setx NUGET_API_KEY "your-real-key-here" + # Then close and reopen your terminal / Visual Studio so the new value is loaded. + $ApiKey = $env:NUGET_API_KEY + + if ([string]::IsNullOrWhiteSpace($ApiKey)) { + Write-Error "Environment variable NUGET_API_KEY is not set. Run 'setx NUGET_API_KEY your-key' and restart your terminal." + } + # Note: previous builds are kept intentionally, so you have a local history # of every version ever packed. We don't delete anything here. if (-not (Test-Path $artifactsPath)) { @@ -203,16 +214,13 @@ try { # Pack (auto-bumping the patch version if it was already packed) # --------------------------------------------------------------- - # The version lives in ONE place: Directory.Build.props (the csproj no longer carries it). + # The version lives in ONE place: Directory.Build.props (the CLI assembly reads it and + # the repo's consistency test forbids a second in the csproj). $buildPropsPath = Join-Path $solutionRoot "Directory.Build.props" - if (-not (Test-Path $buildPropsPath)) { - Write-Error "Could not find $buildPropsPath." - } - $version = Get-BuildPropsVersion -Path $buildPropsPath if (-not $version) { - Write-Error "Could not read from $buildPropsPath. Make sure it's set explicitly in Directory.Build.props." + Write-Error "Could not read from $buildPropsPath. Make sure it's set explicitly there." } $originalVersion = $version @@ -234,7 +242,7 @@ try { if ($version -ne $originalVersion) { Set-BuildPropsVersion -Path $buildPropsPath -NewVersion $version Write-Step "updated in $buildPropsPath : $originalVersion -> $version" - Add-Summary "Directory.Build.props updated: Version $originalVersion -> $version" + Add-Summary "Directory.Build.props updated: $ProjectName Version $originalVersion -> $version" } else { Add-Summary "Version used: $ProjectName $version (no bump needed)" } @@ -247,41 +255,37 @@ try { } Add-Summary "Packed: $ProjectName v$version -> $artifactsPath" - # The package we just built has the EXACT name for the current version — never pick - # "newest *.nupkg by timestamp", which can grab the wrong file if anything else - # touched the folder after the pack. - $nupkgPath = Join-Path $artifactsPath "$ProjectName.$version.nupkg" + # Multiple versions may already sit in this folder from previous runs. + # The one we just built is always the most recently modified file, so we + # sort by LastWriteTime and take the newest — nothing else gets touched. + $nupkg = Get-ChildItem -Path $artifactsPath -Filter "*.nupkg" | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 - if (-not (Test-Path $nupkgPath)) { - Write-Error "The package for v$version was not found after packing: $nupkgPath" + if (-not $nupkg) { + Write-Error "No .nupkg file was found after packing." } $snupkgPath = Join-Path $artifactsPath "$ProjectName.$version.snupkg" - Write-Step "package built: $(Split-Path $nupkgPath -Leaf)" + Write-Step "package built: $($nupkg.Name)" if ($SkipPush) { Write-Step "SkipPush is set; packed only, not pushed." - Add-Summary "Push skipped (-SkipPush): $(Split-Path $nupkgPath -Leaf) left in $artifactsPath" + Add-Summary "Push skipped (-SkipPush): $($nupkg.Name) left in $artifactsPath" return } # --------------------------------------------------------------- - # Push (timeout; on failure, remove the artifacts we just built + # Push (15s timeout; on failure, remove the artifacts we just built # so the next run doesn't see a "used" version and bump for no reason) # --------------------------------------------------------------- - # The API key is needed ONLY for the push: pack-only runs (-SkipPush) work without it. Write-Action "pushing package to nuget ..." - $ApiKey = $env:NUGET_API_KEY - if ([string]::IsNullOrWhiteSpace($ApiKey)) { - Write-Error "Environment variable NUGET_API_KEY is not set. Run 'setx NUGET_API_KEY your-key' and restart your terminal." - } - $pushTimeoutSeconds = 30 $pushArgs = @( - 'nuget', 'push', $nupkgPath, + 'nuget', 'push', $nupkg.FullName, '--source', 'https://api.nuget.org/v3/index.json', '--api-key', $ApiKey, '--skip-duplicate', @@ -301,10 +305,10 @@ try { if ($pushResult.TimedOut -or $pushResult.ExitCode -ne 0) { Write-Step "removing the artifacts just built for v$version so the version isn't wasted." - if (Test-Path $nupkgPath) { - Remove-Item $nupkgPath -Force - Write-Step "removed $(Split-Path $nupkgPath -Leaf)" - Add-Summary "Removed after failed push: $(Split-Path $nupkgPath -Leaf)" + if (Test-Path $nupkg.FullName) { + Remove-Item $nupkg.FullName -Force + Write-Step "removed $($nupkg.Name)" + Add-Summary "Removed after failed push: $($nupkg.Name)" } if (Test-Path $snupkgPath) { Remove-Item $snupkgPath -Force @@ -316,8 +320,15 @@ try { Write-Error "Push failed." } - Write-Step "$(Split-Path $nupkgPath -Leaf) was successfully published to NuGet.org." - Add-Summary "Pushed to NuGet.org: $(Split-Path $nupkgPath -Leaf)" + Write-Step "$($nupkg.Name) was successfully published to NuGet.org." + Add-Summary "Pushed to NuGet.org: $($nupkg.Name)" + + # NOTE: there is intentionally no "sync Directory.Packages.props" or "bump dependent + # projects" step here. Nothing in this repo consumes KeylessAgent packages via + # — Core references Abstractions, and Cli references Core, both via + # . If that ever changes (e.g. Abstractions/Core get published too and + # something else in this repo takes a PackageReference on them), port that logic back in + # from publish.ps1 in ImanSoftware.Platform / ImanSoftware.DepLens. } finally { Show-Summary diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Chat/ChatSessionOptions.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Chat/ChatSessionOptions.cs index 519db06..8b9b8f2 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Chat/ChatSessionOptions.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Chat/ChatSessionOptions.cs @@ -27,4 +27,10 @@ public sealed record ChatSessionOptions /// Optional per-phase timing recorder (the --timing table). Null when timing is off. ///

public ITimingRecorder? Timings { get; init; } + + /// + /// Optional sink for session-level events (e.g. send attempts with their observed + /// signals) — the agent loop wires this into the turn log. Null when no log is wanted. + /// + public Func? LogEvent { get; init; } } diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Configuration/KeylessOptions.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Configuration/KeylessOptions.cs index bffa5cd..5e26f35 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Configuration/KeylessOptions.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Configuration/KeylessOptions.cs @@ -8,6 +8,22 @@ namespace ImanSoftware.KeylessAgent.Abstractions.Models.Configuration; /// public sealed record KeylessOptions { + /// The current keyless.json schema version. + public const int CurrentSchemaVersion = 2; + + /// + /// The keyless.json schema version this file was written for. Null means the file was + /// written before versioning existed (schema version 1) and it will be migrated on load. + /// + public int? SchemaVersion { get; init; } + + /// + /// Creates the current default configuration with the schema version set, for fresh + /// workspaces and for the schema migration. + /// + public static KeylessOptions CreateDefault() => new() { SchemaVersion = CurrentSchemaVersion }; + + /// /// How much confirmation the agent asks for. Default: . /// @@ -37,6 +53,23 @@ public sealed record KeylessOptions /// External command policy settings. /// public CommandsOptions Commands { get; init; } = new(); + + /// + /// Pacing of the model's replies: how many non-say commands one response may carry. + /// + public AgentOptions Agent { get; init; } = new(); +} + +/// +/// The Agent section: reply pacing. The effective per-response command limit is +/// min(Pace limit, Limits.MaxCommandsPerResponse). +/// +public sealed record AgentOptions +{ + /// + /// Step = 1 command per response, Balanced = 3 (default), Batch = Limits.MaxCommandsPerResponse. + /// + public AgentPace Pace { get; init; } = AgentPace.Balanced; } /// @@ -112,31 +145,38 @@ public sealed record BrowserOptions public ReplySourceMode ReplySource { get; init; } = ReplySourceMode.Auto; /// - /// How long to wait for a complete reply. Default 300 seconds. + /// How long to wait for a complete reply. Default 180 seconds. /// - public int ReplyTimeoutSeconds { get; init; } = 300; + public int ReplyTimeoutSeconds { get; init; } = 180; /// /// How long to wait for confirmation that a message was actually sent (new message item, - /// or empty input plus generating indicator) before retrying once and failing. Default 15 seconds. + /// or empty input plus generating indicator) before retrying once and failing. Default 8 seconds. /// - public int SendConfirmSeconds { get; init; } = 15; + public int SendConfirmSeconds { get; init; } = 8; /// /// How long to wait for attachment uploads to settle (chips visible, send enabled) - /// before falling back to pasting the content. Default 60 seconds. + /// before falling back to pasting the content. Default 15 seconds. + /// + public int UploadWaitSeconds { get; init; } = 15; + + /// + /// When the machine is offline before a send, wait up to this many seconds for the + /// connection to come back (polling every 2 seconds) instead of failing immediately. + /// Default 0 — fail at once with KA_NETWORK_OFFLINE. /// - public int UploadWaitSeconds { get; init; } = 60; + public int WaitForNetworkSeconds { get; init; } = 0; /// /// Minimum artificial delay between browser actions. Default 400 ms. /// - public int MinDelayMs { get; init; } = 0; + public int MinDelayMs { get; init; } = 400; /// /// Maximum artificial delay between browser actions. Default 1500 ms. /// - public int MaxDelayMs { get; init; } = 150; + public int MaxDelayMs { get; init; } = 1500; /// /// Rate limit for messages sent to the chat. Default 6 per minute. @@ -154,16 +194,38 @@ public sealed record BrowserOptions public bool HumanDelay { get; init; } /// - /// Browser window behavior: Minimized (default, with throttling flags), Offscreen - /// (--window-position=-32000,-32000), Normal. Only affects auto-launched Chrome. + /// Browser window behavior. Accepted values: "Offscreen", "Minimized", "TinyCorner", + /// "EdgePeek", "Normal". Default "TinyCorner": a visible-but-imperceptible 80x80 window + /// in the bottom-right corner of the primary screen. The window MUST be visible-on-screen + /// (at least 80x80 pixels) to prevent Chrome's occlusion throttling — fully offscreen + /// positions (-32000,-32000, "Offscreen"/"Minimized") and truly minimized windows are + /// treated as occluded by Chrome 130+ on Windows 11 24H2 regardless of the + /// --disable-features=CalculateNativeWinOcclusion flag, and the renderer throttle + /// truncates DeepSeek streaming replies (turn-17/18 evidence). Normal = never forced. + /// Only affects auto-launched Chrome; every hide path uses the same tiny-corner geometry. /// - public string WindowMode { get; init; } = "Minimized"; + public string WindowMode { get; init; } = "TinyCorner"; /// /// Minimize the window again after the login completes. Default true. /// public bool MinimizeAfterLogin { get; init; } = true; + /// + /// Un-minimize the chat window ONCE when a throttled reply stream has been rejected for + /// a quarter of TruncationBudgetSeconds in a single reply wait. Default false: the + /// window stays hidden for the whole turn (focusing it steals the terminal from the user). + /// + public bool AutoFocusOnTruncation { get; init; } + + /// + /// Wall-clock budget for a THROTTLED reply stream: when truncation detections span + /// longer than this (time since the FIRST detection, not an attempt count), the reply + /// wait fails with KA_REPLY_TRUNCATED. Default 20 seconds - enough for a stream that + /// resumes after a few real seconds, quick enough to fail a permanently stuck one. + /// + public int TruncationBudgetSeconds { get; init; } = 20; + /// /// Upper bound of the positive-first login probe: wait this long for EITHER the chat /// input (logged in) OR a login indicator, in one parallel wait. Default 15 seconds. @@ -213,9 +275,29 @@ public sealed record LimitsOptions public sealed record SnapshotOptions { /// - /// What gets attached to a user request. Default . + /// What gets attached to a user request. Default : + /// the tree plus only files changed since the last baseline. Full remains fully supported + /// for anyone who explicitly wants it. When TreeAndChanged has no usable baseline (first + /// turn), the attachment falls back to the tree plus a BUDGET-LIMITED relevant-file dump + /// (see ), never an unlimited full dump. /// - public SnapshotMode Mode { get; init; } = SnapshotMode.Full; + public SnapshotMode Mode { get; init; } = SnapshotMode.TreeAndChanged; + + /// + /// Total character ceiling for everything attached to ONE turn (tree + dump + notes + /// combined; chars/4 is a rough token estimate for DeepSeek). When exceeded: the tree is + /// always kept, the dump keeps only files referenced by the turn's request (plus the + /// TreeAndChanged diff when active), and everything else becomes a one-line omission + /// note pointing at the file-read tool. Default 400,000. + /// + public int MaxTotalAttachmentChars { get; init; } = 400_000; + + /// + /// Per-file cap for the attached code dump: a file whose fenced content exceeds this is + /// replaced by its heading plus a "[file too large to attach: N KB …]" placeholder line, + /// keeping the file's existence visible without its full content. Default 50,000. + /// + public int MaxDumpedFileChars { get; init; } = 50_000; /// /// Whether the snapshot is also attached to result messages. Default false. @@ -248,6 +330,17 @@ public sealed record SnapshotFilterOptions /// public IReadOnlyList ExcludeDumpHeadings { get; init; } = ["tree-view-result", "code-dump-result"]; + /// + /// Generated/vendored files excluded from the ATTACHED dump by file-name wildcard + /// (case-insensitive *, like SensitiveFilePatterns): minified bundles, lock files + /// and generated designer/migration snapshots are huge and rarely useful to attach. + /// + public IReadOnlyList ExcludeFilePatterns { get; init; } = + [ + "*.min.js", "*.min.css", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", + "*.designer.cs", "*.g.cs", "*.g.i.cs", "*ModelSnapshot.cs", + ]; + /// /// Files whose sections are dropped from the ATTACHED copy entirely (never from disk): /// case-insensitive * wildcards matched against the file name. Environment files, @@ -312,7 +405,7 @@ public sealed record CommandsOptions public string BatchFailureMode { get; init; } = "StopOnMutationFailure"; /// - /// Timeout for every external process. Default 300 seconds. + /// Timeout for every external process. Default 180 seconds. /// - public int TimeoutSeconds { get; init; } = 300; + public int TimeoutSeconds { get; init; } = 180; } diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/AgentPace.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/AgentPace.cs new file mode 100644 index 0000000..79d4c78 --- /dev/null +++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/AgentPace.cs @@ -0,0 +1,17 @@ +namespace ImanSoftware.KeylessAgent.Abstractions.Models.Enums; + +/// +/// How many non-say commands the model may put into ONE response. The effective limit is +/// min(Pace limit, Limits.MaxCommandsPerResponse). +/// +public enum AgentPace +{ + /// One command per response — verify every result before the next step. + Step = 0, + + /// At most 3 non-say commands per response (default). + Balanced = 1, + + /// Up to Limits.MaxCommandsPerResponse non-say commands per response. + Batch = 2, +} diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/CommandKind.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/CommandKind.cs index d423101..433f69a 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/CommandKind.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/CommandKind.cs @@ -43,4 +43,7 @@ public enum CommandKind /// Moves/renames a file: argument = source path, TO payload = destination path. [Description("movefile")] MoveFile, + + /// Creates a folder (and parents). Idempotent; never confirmed. + [Description("mkdir")] Mkdir, } diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/ErrorCodes.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/ErrorCodes.cs index aea4211..e95e803 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/ErrorCodes.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/ErrorCodes.cs @@ -118,12 +118,24 @@ public static class ErrorCodes /// No reply arrived within the configured timeout. public const string ReplyTimeout = "KA_REPLY_TIMEOUT"; + /// The reply stream stayed truncated (AgentScript without @@eof) longer than Browser.TruncationBudgetSeconds in one reply wait. + public const string ReplyTruncated = "KA_REPLY_TRUNCATED"; + /// The message could not be sent (not confirmed in the DOM, retry failed too). public const string SendNotConfirmed = "KA_SEND_NOT_CONFIRMED"; /// Uploading attachments to the chat failed. public const string AttachmentUploadFailed = "KA_ATTACHMENT_UPLOAD_FAILED"; + /// The machine is offline: nothing was uploaded or sent. + public const string NetworkOffline = "KA_NETWORK_OFFLINE"; + + /// The composer still holds stale text or attachment chips after the reset attempt. + public const string ComposerNotClean = "KA_COMPOSER_NOT_CLEAN"; + + /// A runcommand's tool is listed as Missing in the environment — the process was never started. + public const string ToolMissing = "KA_TOOL_MISSING"; + /// The chat hit its length limit and could not be continued. public const string ChatExhausted = "KA_CHAT_EXHAUSTED"; public const string Cancelled = "KA_CANCELLED"; diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Parser/AgentScriptCommand.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Parser/AgentScriptCommand.cs index a0b4856..be287d6 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Parser/AgentScriptCommand.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Parser/AgentScriptCommand.cs @@ -159,6 +159,17 @@ public sealed record NextCommand(string Message) : AgentScriptCommand /// Moves/renames a file (sandboxed, backed up, refuses to overwrite). /// Source path, workspace-relative. /// Destination path, workspace-relative. +/// Creates a folder (and missing parents). Idempotent: an existing folder is fine. +/// Workspace-relative path using forward slashes. +public sealed record MkdirCommand(string Path) : AgentScriptCommand +{ + /// + public override CommandKind Kind => CommandKind.Mkdir; + + /// + public override string Target => Path; +} + public sealed record MoveFileCommand(string Path, string ToPath) : AgentScriptCommand { /// diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IAgentLoop.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IAgentLoop.cs index ffbb0ae..9010bb3 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IAgentLoop.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IAgentLoop.cs @@ -1,4 +1,5 @@ using ImanSoftware.KeylessAgent.Abstractions.Models.Agent; +using ImanSoftware.KeylessAgent.Abstractions.Models.Enums; namespace ImanSoftware.KeylessAgent.Abstractions.Services; @@ -18,11 +19,15 @@ public interface IAgentLoop : IService /// The UI contract that shows progress, says, questions and diffs. /// Print what would happen; execute nothing. /// Cancels the turn (the snapshot still refreshes). + /// Ignore the cached environment (.agent/env.json) and re-probe. + /// Overrides Agent.Pace from keyless.json for this turn (CLI/WPF toggle). /// The turn result (branch, commits, summary, stop reason). Task RunAsync( string workspaceRoot, string request, IAgentUserInterface ui, bool dryRun = false, - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + bool refreshEnv = false, + AgentPace? paceOverride = null); } diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IConfigurationService.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IConfigurationService.cs index e3befe2..2c8be37 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IConfigurationService.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IConfigurationService.cs @@ -11,9 +11,17 @@ public interface IConfigurationService : IService /// /// Loads keyless.json from the workspace root. When the file is missing, /// the default configuration is written to it and returned. Invalid JSON or out-of-range - /// values become precise failures, never exceptions. + /// values become precise failures, never exceptions. A file written before the current + /// schema version is migrated: the raw file is backed up as + /// keyless.json.v{version}.bak and the current defaults replace stale values. /// /// The workspace directory. /// The effective configuration. Task> LoadAsync(string workspaceRoot); + + /// + /// Whether the most recent migrated a pre-schema-version + /// keyless.json to the current defaults. Reset by every LoadAsync call. + /// + bool LastLoadMigratedSchema { get; } } diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IResultFormatter.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IResultFormatter.cs index 4dca1ce..654db3c 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IResultFormatter.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IResultFormatter.cs @@ -17,6 +17,7 @@ public interface IResultFormatter : IService /// The executed commands' results, including skipped ones. /// The per-response command limit (for the skipped note). /// The configured limits, for output capping. + /// The workspace directory, for verifying file results on disk. /// The complete message text. - string Format(int turn, int batch, IReadOnlyList results, int batchLimit, LimitsOptions limits); + string Format(int turn, int batch, IReadOnlyList results, int batchLimit, LimitsOptions limits, string workspaceRoot); } diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ISnapshotService.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ISnapshotService.cs index a52b6ae..48f6953 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ISnapshotService.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ISnapshotService.cs @@ -26,7 +26,15 @@ public interface ISnapshotService : IService /// The workspace directory. /// The effective configuration. /// One tree attachment plus one or more code-dump parts. - Task>> BuildAttachmentsAsync(string workspaceRoot, KeylessOptions options); + /// + /// Builds the attachments for one turn. (the turn's + /// request) drives the size-budget selection: referenced files keep full content, + /// everything else becomes an omission note. receives the + /// snapshot-budget-exceeded event. + /// + Task>> BuildAttachmentsAsync( + string workspaceRoot, KeylessOptions options, + string? requestText = null, Func? logEvent = null); /// /// Whether the outputs are missing or older than the newest workspace source file diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Factory/KeylessAgentServiceFactory.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Factory/KeylessAgentServiceFactory.cs index 019e66b..5065004 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Factory/KeylessAgentServiceFactory.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Factory/KeylessAgentServiceFactory.cs @@ -42,8 +42,8 @@ public static IAgentLoop CreateAgentLoop(IChatProvider chatProvider, ILoggerFact /// Creates the configuration service. /// /// The configuration service. - public static IConfigurationService CreateConfigurationService() - => new ConfigurationService(); + public static IConfigurationService CreateConfigurationService(Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null) + => new ConfigurationService(loggerFactory: loggerFactory); /// /// Creates the git service. @@ -94,6 +94,14 @@ public static ISessionStore CreateSessionStore() public static IWorkspaceInspector CreateWorkspaceInspector() => new WorkspaceInspector(); + /// + /// Creates the environment-tools facade (probed git/dotnet/node/npm/python states with + /// caching, per-tool re-probes and invalidation) for the fail-fast rule and the UIs. + /// + /// The environment tools. + public static IEnvironmentTools CreateEnvironmentTools() + => new EnvironmentProbe(); + /// /// Creates the change tracker (git porcelain or no-git index). /// @@ -122,4 +130,13 @@ public static IChatDiagnostics CreateChatDiagnostics() /// The parser. public static IAgentScriptParser CreateParser() => new AgentScriptParser(); + + /// + /// Creates the Chrome window show/hide controller (pure CDP window moves; powers + /// keyless show/keyless hide, the doctor page button and the login flow). + /// Pass a logger factory to receive the CDP call lines in the workspace log. + /// + /// The window controller. + public static IChromeWindowController CreateChromeWindowController(ILoggerFactory? loggerFactory = null) + => new ChromeWindowController(loggerFactory: loggerFactory); } diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/AgentLoop.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/AgentLoop.cs index d4df143..8bee3d3 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/AgentLoop.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/AgentLoop.cs @@ -95,7 +95,24 @@ internal AgentLoop( /// /// A compact fixed reminder (~8 lines) prepended to every user-request message. /// - private static string BuildPerMessageReminder(string envSummary, bool noGitMode) + + /// + /// Counts the numbered plan lines ("1. …", "2) …") in the reply's FIRST say block. + /// Returns null when the reply has no say or no numbered plan. + /// + private static int? CountPlanSteps(IReadOnlyList commands) + { + var say = commands.FirstOrDefault(c => c.Kind == CommandKind.Say) as SayCommand; + if (say is null) + return null; + + var numbered = say.Message + .Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries) + .Count(line => System.Text.RegularExpressions.Regex.IsMatch(line, "^ *[0-9]+[.)] ")); + return numbered > 0 ? numbered : null; + } + + private static string BuildPerMessageReminder(string envSummary, bool noGitMode, AgentPace pace) { var gitNote = noGitMode ? "git is NOT installed — do not use @@commit or git commands." @@ -103,12 +120,19 @@ private static string BuildPerMessageReminder(string envSummary, bool noGitMode) return string.Join("\n", "[REMINDER] AgentScript v1 — reply ONLY with blocks from this set:", - "@@say / @@newfile / @@updatefile / @@editfile / @@deletefile / @@readfile / @@runcommand / @@ask / @@commit / @@movefile + <<>> / @@next + optional <<>> / @@done", - "Each block: @@keyword [argument] line, optional <<>> payload (markers alone on their own line), then @@end. End with @@eof. No @@command, no JSON, no markdown fence.", + "@@say / @@newfile / @@updatefile / @@editfile / @@deletefile / @@readfile / @@runcommand / @@ask / @@commit / @@mkdir / @@movefile + <<>> / @@next + optional <<>> / @@done", + "The WHOLE reply is plain AgentScript v1 - no outer markdown fence around the reply. BUT every CONTENT payload inside @@newfile or @@updatefile MUST be wrapped in its own triple-backtick fence: a ``` (or ```html) line, then the file content, then a ``` line BEFORE CONTENT>>>. The program strips that inner fence before writing the file; without it the chat renderer collapses newlines and the reply is corrupted.", + "Example: @@newfile src/x.html, then <<>>, then @@end.", + "Markers are ALONE on their own line, then @@end ends the block. End with @@eof. No @@command, no JSON envelope.", + "Every payload marker (<<>>) MUST be ALONE on its own line — never chain \"CONTENT>>> @@end @@newfile x\" on one line.", "You CAN create/modify files with @@newfile/@@updatefile/@@editfile/@@movefile — you DO execute commands yourself via @@runcommand. Never give the user commands to run.", + "Missing tools: never edit, move or create files via python/node/shell when a file command exists; if a needed tool is missing (e.g. dotnet for a build), say so in @@say, skip that verification, finish the rest, and tell the user what to install — never retry a missing tool.", $"Environment: {envSummary}", + "Commands run in cmd.exe on Windows (built-ins like mkdir/dir/type and && chains work); for files and folders prefer @@newfile/@@editfile/@@movefile — a new file's parent folders are created automatically.", gitNote, - "Investigate first (read-only commands, readfile) instead of guessing; use @@next to get results before continuing; use @@done to end the turn.", + pace == AgentPace.Step + ? "Pace is STEP: send ONE command, then @@next, and verify the result (verification summary or readfile) before the next command." + : "Group at most 3 related commands, verify after each group.", "[/REMINDER]"); } @@ -117,7 +141,9 @@ public async Task RunAsync( string request, IAgentUserInterface ui, bool dryRun = false, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool refreshEnv = false, + AgentPace? paceOverride = null) { var logger = new TurnLogger(ImanSoftware.FileStorage.ImanFileStorageFactory.CreateStorage(), _time); KeylessOptions options = new(); @@ -136,6 +162,9 @@ public async Task RunAsync( var timings = new TimingRecorder(); var runCommandCount = 0; var baseBranch = string.Empty; + // User interactions (approvals, menus, @@ask) are timed as "user-wait" so the + // summary can report the turn with and without time spent waiting for the user. + ui = new UserWaitTimingUi(ui, timings); var countingUi = new CommandCountingUserInterface(ui); try @@ -147,8 +176,39 @@ public async Task RunAsync( if (configOutcome.IsFailure) return Failed(configOutcome.Error.Message, dryRun); options = configOutcome.Data; + if (_configuration.LastLoadMigratedSchema) + { + ui.Say("[!] keyless.json in this workspace was upgraded to the current schema. " + + "Previous defaults like ReplyTimeoutSeconds 300, SendConfirmSeconds 15 and UploadWaitSeconds 60 " + + "have been replaced with the new tuned defaults. The old file is backed up as keyless.json.v1.bak in the workspace root."); + } timings.Stop("config"); + // The pace (CLI/WPF override or keyless.json) decides how many non-say commands + // one response may carry: min(Pace limit, Limits.MaxCommandsPerResponse). Step + // pace also raises MaxIterationsPerTurn to at least 60 (one command per response + // needs more rounds) and says so once. + var pace = paceOverride ?? options.Agent.Pace; + var paceLimit = pace switch + { + AgentPace.Step => 1, + AgentPace.Balanced => 3, + _ => int.MaxValue, + }; + var effectiveCommandLimit = Math.Min(paceLimit, options.Limits.MaxCommandsPerResponse); + var effectiveIterations = options.Limits.MaxIterationsPerTurn; + if (pace == AgentPace.Step && effectiveIterations < 60) + { + effectiveIterations = 60; + ui.ShowProgress(new AgentProgressEvent(AgentEventType.SessionStarting, + "Step pace: MaxIterationsPerTurn raised to 60 for this turn.")); + } + + // The system prompt must describe the pace actually used this turn. + options = options with { Agent = options.Agent with { Pace = pace } }; + var executedNonSayTotal = 0; // turn-scoped counter for the "Step k/N" plan labels + var turnPlanSteps = (int?)null; // captured from the first say with a numbered plan + if (options.Mode is ApprovalMode.AutoSafe or ApprovalMode.Auto) { // Programs like dotnet/node/python execute arbitrary code; the allow list is @@ -207,13 +267,11 @@ public async Task RunAsync( await _git.EnsureAgentPathsExcludedAsync(workspaceRoot); timings.Stop("git-checks"); - // --- Step 2c: Environment probe (parallel, 3s timeout each) --- - timings.Start("env-probe"); + // --- Step 2c: environment probe STARTS here and runs CONCURRENTLY with the + // browser connection below; cached in .agent/env.json for 24h (keyed by the + // workspace itself). Its awaited result is read just before the first send. var environmentProbe = new EnvironmentProbe(); - var env = await environmentProbe.ProbeAsync(workspaceRoot, cancellationToken); - var envBlock = env.IsSuccess ? env.Data.Block : "[ENVIRONMENT unavailable]"; - var envSummary = env.IsSuccess ? env.Data.Summary : ""; - timings.Stop("env-probe"); + var envTask = environmentProbe.ProbeCachedAsync(workspaceRoot, refreshEnv, cancellationToken); // --- Step 3+4: Git preconditions (menus) --- branch = noGitMode ? string.Empty : capabilities.CurrentBranch; @@ -272,10 +330,29 @@ public async Task RunAsync( dirtyOptions, DefaultId: isAgentBranch ? "here" : "carry"); + // On an agent branch the menu usually means the USER changed something the + // agent does not know about: name up to 5 paths that are not agent-made. + if (isAgentBranch) + { + var notAgentMade = (await _changeTracker.DiffAsync(workspaceRoot, changeBaseline!, !noGitModeUsed, cancellationToken)) + .Data? + .Where(c => agentChangedPaths is null || !agentChangedPaths.Contains(c.Path, StringComparer.OrdinalIgnoreCase)) + .Select(c => c.Path) + .Take(5) + .ToList(); + + if (notAgentMade is { Count: > 0 }) + { + ui.Say("[?] Changes I did not make: " + string.Join(", ", notAgentMade)); + _log.LogWarning("Dirty menu shown on an agent branch; paths not in AgentChangedPaths: {Paths}", + string.Join(", ", notAgentMade)); + } + } + var dirtyChoice = await ui.ChooseAsync(dirtyMenu, cancellationToken); lastDirtyChoice = dirtyChoice.SelectedId; if (dirtyChoice.Cancelled || dirtyChoice.SelectedId == "cancel") - return Failed("Cancelled by the user (dirty working tree).", dryRun); + return Failed("Cancelled by the user (dirty working tree).", dryRun, ErrorCodes.Cancelled); if (dirtyChoice.SelectedId == "stash") { @@ -371,12 +448,18 @@ public async Task RunAsync( WorkspaceRoot = workspaceRoot, ResumeChatUrl = session?.ChatUrl, Timings = timings, + LogEvent = (type, payload) => logger.AppendAsync(type, payload, CancellationToken.None), }, cancellationToken); if (chatOutcome.IsFailure) return Failed(chatOutcome.Error.Message, dryRun); await using var chatSession = chatOutcome.Data; timings.Stop("connect"); + + // The environment probe ran concurrently with the connection; collect its result. + var env = await envTask; + var envBlock = env.IsSuccess ? env.Data.Block : "[ENVIRONMENT unavailable]"; + var envSummary = env.IsSuccess ? env.Data.Summary : ""; savedSession = new AgentSession { ChatUrl = chatSession.ChatUrl, @@ -402,7 +485,7 @@ public async Task RunAsync( // --- Step 7: Send the request (system prompt on the first message of a new chat) --- timings.Start("attachments"); - var attachmentsOutcome = await _snapshot.BuildAttachmentsAsync(workspaceRoot, options); + var attachmentsOutcome = await _snapshot.BuildAttachmentsAsync(workspaceRoot, options, request, (type, payload) => logger.AppendAsync(type, payload, cancellationToken)); timings.Stop("attachments"); var attachments = attachmentsOutcome.IsSuccess ? attachmentsOutcome.Data : []; if (attachmentsOutcome.IsFailure) @@ -457,7 +540,7 @@ public async Task RunAsync( // Message 2: the user's request + a compact AgentScript reminder + the workspace // snapshot attachments. The full system prompt stays on the first message only. - var reminder = BuildPerMessageReminder(envSummary, noGitModeUsed); + var reminder = BuildPerMessageReminder(envSummary, noGitModeUsed, pace); var message = reminder + "\n\n" + request; if (!dryRun) { @@ -476,7 +559,7 @@ public async Task RunAsync( // --- Step 8: The conversation loop --- var formatRetries = 0; var batch = 0; - while (batch < options.Limits.MaxIterationsPerTurn) + while (batch < effectiveIterations) { batch++; cancellationToken.ThrowIfCancellationRequested(); @@ -531,17 +614,16 @@ public async Task RunAsync( var verdict = verdictOutcome.Data; var script = verdict.Script!; - // Compact per-reply summary (the raw panel stays reserved for invalid replies - // and -v debugging): one line instead of the raw event dump. Script is null - // for invalid replies, where the raw panel below tells the user what broke. - if (verdict.Script is not null) + // ONE compact per-reply summary line for VALID replies (the raw panel stays + // reserved for invalid replies and -v debugging): it names the command mix + // and where the reply text came from. + if (verdict.IsValid) { - var kinds = script.Commands + var kinds = string.Join(", ", script.Commands .GroupBy(c => c.Kind) - .Select(g => $"{g.Key} x{g.Count()}"); - ui.ShowProgress(new AgentProgressEvent( - AgentEventType.ValidatingReply, - $"Reply: {script.Commands.Count} commands ({string.Join(", ", kinds)})")); + .Select(g => $"{g.Key} x{g.Count()}")); + ui.ShowProgress(new AgentProgressEvent(AgentEventType.ValidatingReply, + $"Reply: {script.Commands.Count} commands ({kinds}) via {replySource}")); } // Show the raw reply panel ONLY when invalid (the user needs to see what went @@ -555,12 +637,6 @@ public async Task RunAsync( : reply[..MaxConsoleReplyChars] + $"… ({reply.Length - MaxConsoleReplyChars} more; full text in .agent/logs)"; ui.Say($"[raw reply]{Environment.NewLine}{visibleReply}"); } - else - { - var commandKinds = string.Join(", ", script.Commands.Select(c => c.Kind.ToString().ToLowerInvariant())); - ui.ShowProgress(new AgentProgressEvent(AgentEventType.ValidatingReply, - $"Reply: {script.Commands.Count} commands ({commandKinds}) via {replySource}")); - } if (!verdict.IsValid) { if (formatRetries >= options.Limits.MaxFormatRetries) @@ -604,6 +680,10 @@ public async Task RunAsync( formatRetries = 0; // --- Step 8b: Execute (say always; others up to the batch limit; rest skipped) --- + // The model's plan (a short numbered list in the first say) drives the + // "Step k/N" progress labels; missing or unparsable plans show nothing. + turnPlanSteps ??= CountPlanSteps(script.Commands); + var planSteps = turnPlanSteps; var executed = new List(); var usedSlots = 0; var stopBatch = false; @@ -619,7 +699,7 @@ public async Task RunAsync( continue; } - if (usedSlots >= options.Limits.MaxCommandsPerResponse) + if (usedSlots >= effectiveCommandLimit) { executed.Add(new CommandExecutionResult { @@ -627,14 +707,18 @@ public async Task RunAsync( Kind = command.Kind, Target = command.Target, Status = CommandStatus.Skipped, - ErrorMessage = "batch limit reached", + ErrorMessage = paceLimit < options.Limits.MaxCommandsPerResponse + ? $"pace={pace}: only {effectiveCommandLimit} command(s) per response; send the next one after reading this result" + : "batch limit reached", }); continue; } usedSlots++; + executedNonSayTotal++; + var planNote = planSteps is not null ? $"Step {Math.Min(executedNonSayTotal, planSteps.Value)}/{planSteps.Value}: " : string.Empty; ui.ShowProgress(new AgentProgressEvent(AgentEventType.ExecutingCommand, - $"#{command.Number} {command.Kind} {command.Target}")); + $"{planNote}#{command.Number} {command.Kind} {command.Target}")); if (stopBatch) { @@ -741,20 +825,20 @@ public async Task RunAsync( break; } - if (batch >= options.Limits.MaxIterationsPerTurn) + if (batch >= effectiveIterations) { - stoppedReason = $"iteration limit reached ({options.Limits.MaxIterationsPerTurn} responses)"; + stoppedReason = $"iteration limit reached ({effectiveIterations} responses)"; break; } // --- Step 8c: Send the per-command results back --- timings.Start("attachments"); var feedbackAttachments = options.Snapshot.AttachOnFeedback - ? (await _snapshot.BuildAttachmentsAsync(workspaceRoot, options)).Data ?? [] + ? (await _snapshot.BuildAttachmentsAsync(workspaceRoot, options, requestText: null, logEvent: (type, payload) => logger.AppendAsync(type, payload, cancellationToken))).Data ?? [] : []; timings.Stop("attachments"); - var resultsMessage = _resultFormatter.Format(turn, batch, executed, options.Limits.MaxCommandsPerResponse, options.Limits); + var resultsMessage = _resultFormatter.Format(turn, batch, executed, options.Limits.MaxCommandsPerResponse, options.Limits, workspaceRoot); ui.ShowProgress(new AgentProgressEvent(AgentEventType.SendingResults, "Sending results")); await logger.AppendAsync("results-sent", new { Turn = turn, Batch = batch, Results = executed.Count }, cancellationToken); if (!dryRun) diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/ResultFormatter.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/ResultFormatter.cs index 2d4eb98..9682582 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/ResultFormatter.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/ResultFormatter.cs @@ -15,7 +15,7 @@ namespace ImanSoftware.KeylessAgent.Core.Implementation.Agent; internal sealed class ResultFormatter : IResultFormatter { /// - public string Format(int turn, int batch, IReadOnlyList results, int batchLimit, LimitsOptions limits) + public string Format(int turn, int batch, IReadOnlyList results, int batchLimit, LimitsOptions limits, string workspaceRoot) { var builder = new System.Text.StringBuilder(); builder.AppendLine(CultureInfo.InvariantCulture, $"[KEYLESSAGENT RESULTS] turn={turn} batch={batch}"); @@ -45,6 +45,10 @@ public string Format(int turn, int batch, IReadOnlyList if (result.ExitCode.HasValue) builder.Append("exit code: ").Append(result.ExitCode.Value).AppendLine(result.DurationSeconds.HasValue ? $" | duration: {result.DurationSeconds.Value:F1}s" : string.Empty); + var verified = VerifyOnDisk(result, workspaceRoot); + if (verified is not null) + builder.AppendLine(verified); + if (!string.IsNullOrEmpty(result.Output)) { // Caps count characters of the normalized text: CRLF files must not double-count. @@ -55,9 +59,12 @@ public string Format(int turn, int batch, IReadOnlyList } } - var skipped = results.Count(r => r.Status == CommandStatus.Skipped); - if (skipped > 0) - builder.AppendLine(CultureInfo.InvariantCulture, $"--- skipped {skipped} commands: batch limit is {batchLimit} per response, resend them next ---"); + // The "resend them next" hint only applies to batch-limit skips: a command skipped + // after a failure or after @@done must NOT be resent by the model. + var batchLimitSkips = results.Count(r => r.Status == CommandStatus.Skipped && + r.ErrorMessage is not null && r.ErrorMessage.StartsWith("batch limit", StringComparison.Ordinal)); + if (batchLimitSkips > 0) + builder.AppendLine(CultureInfo.InvariantCulture, $"--- skipped {batchLimitSkips} commands: batch limit is {batchLimit} per response, resend them next ---"); builder.AppendLine("[END RESULTS]"); builder.Append("Continue with the next commands, or finish with @@done. Reply in AgentScript v1 only."); @@ -78,6 +85,83 @@ internal static string CapOutput(string output, int maxChars) return $"{output[..head].TrimEnd()}\n[... {cut} characters truncated ...]\n{output[^tail..].TrimStart()}"; } + + /// + /// The compact "verified:" line the model can trust after a file-changing command: + /// path, byte count, line count, an 8-hex SHA-256 prefix and the first/last 60 + /// characters (newlines escaped). Never includes the full content; always under + /// 300 characters. Null when the file is not on disk (dry-run, failure). + /// + internal static string? VerifyOnDisk(CommandExecutionResult result, string workspaceRoot) + { + if (result.Status != CommandStatus.Ok) + return null; + + if (result.Kind == CommandKind.Mkdir) + return Directory.Exists(Path.Combine(workspaceRoot, result.Target)) + ? "verified: dir exists" + : null; + + if (result.Kind is not (CommandKind.NewFile or CommandKind.UpdateFile or CommandKind.EditFile or CommandKind.MoveFile)) + return null; + + // movefile's Target is the OLD path; the destination travels in the output + // ("Moved -> ."). Other kinds verify the target itself. + var relativePath = result.Kind == CommandKind.MoveFile + ? ParseMovedTo(result.Output) + : result.Target; + if (string.IsNullOrWhiteSpace(relativePath)) + return null; + + var fullPath = Path.Combine(workspaceRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(fullPath)) + return null; + + var info = new FileInfo(fullPath); + using var stream = File.OpenRead(fullPath); + var shaBytes = System.Security.Cryptography.SHA256.HashData(stream); + var sha = Convert.ToHexString(shaBytes)[..8]; + + var content = LineEndings.NormalizeToLf(File.ReadAllText(fullPath)); + var newLine = new string(Convert.ToChar(10), 1); + var lines = content.Length == 0 + ? 0 + : content.Count(c => c == Convert.ToChar(10)) + (content.EndsWith(newLine, StringComparison.Ordinal) ? 0 : 1); + + var first = Snippet(content, first: true); + var last = Snippet(content, first: false); + var text = $"verified: path={relativePath.Truncate(60)} bytes={info.Length} lines={lines} sha={sha} first=\"{first}\" last=\"{last}\""; + return text.Length <= 300 ? text : text[..299] + '"'; + } + + /// The first/last 60 characters of the content, newlines shown as the two-character escape. + private static string Snippet(string content, bool first) + { + var lf = Convert.ToChar(10); + var escape = string.Concat(Convert.ToChar(92), 'n'); + var text = content.TrimEnd(lf).Replace(new string(lf, 1), escape); + if (text.Length == 0) + return string.Empty; + return first + ? text.Length <= 60 ? text : text[..60] + : text.Length <= 60 ? text : text[^60..]; + } + + /// Extracts the destination from a movefile result's "Moved -> ." output. + private static string? ParseMovedTo(string? output) + { + if (string.IsNullOrEmpty(output)) + return null; + + var marker = " -> "; + var start = output.IndexOf(marker, StringComparison.Ordinal); + if (start < 0) + return null; + + var end = output.IndexOf('.', start + marker.Length); + return end < 0 ? null : output[(start + marker.Length)..end].Trim(); + } + private static string CommandName(CommandKind kind) => kind.GetDescription(); diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/SystemPromptBuilder.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/SystemPromptBuilder.cs index dee0ca3..eed1cb7 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/SystemPromptBuilder.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/SystemPromptBuilder.cs @@ -1,6 +1,7 @@ using System.Reflection; using ImanSoftware.KeylessAgent.Abstractions.Models; using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration; +using ImanSoftware.KeylessAgent.Abstractions.Models.Enums; using ImanSoftware.Outcomes; namespace ImanSoftware.KeylessAgent.Core.Implementation.Agent; @@ -18,6 +19,30 @@ internal static class SystemPromptBuilder /// /// The effective configuration. /// The prompt text, or a failure when the embedded resource is missing. + + /// + /// The pace-specific working rule injected into the system prompt (guide Phase 12): + /// Step = one command then verify; Balanced = small related groups; Batch = the + /// configured per-response limit. + /// + internal static string PaceRule(AgentPace pace, int maxCommandsPerResponse) + { + var effective = Math.Min(pace switch + { + AgentPace.Step => 1, + AgentPace.Balanced => 3, + _ => maxCommandsPerResponse, + }, maxCommandsPerResponse); + + return pace switch + { + AgentPace.Step => $"Pace is STEP. Work ONE step at a time. Send one command (never two file changes in one reply), then @@next. " + + $"After you read the result, verify it (use the verification summary or readfile) before the next step. Limit: {effective} non-say command(s) per response.", + AgentPace.Balanced => $"Pace is BALANCED: group at most {effective} related commands, verify after each group. Limit: {effective} non-say command(s) per response.", + _ => $"Limit: {effective} non-say command(s) per response; verify after each group.", + }; + } + public static Outcome Build(KeylessOptions options) { var assembly = Assembly.GetExecutingAssembly(); @@ -33,6 +58,7 @@ public static Outcome Build(KeylessOptions options) return Outcome.Successful(template .Replace("{{MAX_COMMANDS_PER_RESPONSE}}", options.Limits.MaxCommandsPerResponse.ToString(System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal) + .Replace("{{PACE_RULE}}", PaceRule(options.Agent.Pace, options.Limits.MaxCommandsPerResponse), StringComparison.Ordinal) .Replace("{{MAX_RESPONSE_CHARS}}", options.Limits.MaxResponseChars.ToString("N0", System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal) .Replace("{{MAX_FILE_CONTENT_CHARS}}", options.Limits.MaxFileContentChars.ToString("N0", System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal) .Replace("{{MAX_COMMAND_OUTPUT_CHARS}}", options.Limits.MaxCommandOutputChars.ToString("N0", System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal) diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/UserWaitTimingUi.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/UserWaitTimingUi.cs new file mode 100644 index 0000000..29c6580 --- /dev/null +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/UserWaitTimingUi.cs @@ -0,0 +1,53 @@ +using ImanSoftware.KeylessAgent.Abstractions.Models.Agent; +using ImanSoftware.KeylessAgent.Abstractions.Services; +using ImanSoftware.KeylessAgent.Core.Implementation.Workspace; + +namespace ImanSoftware.KeylessAgent.Core.Implementation.Agent; + +/// +/// Wrings the user-interaction time out of the other phases: every approval prompt, menu +/// and @@ask question is wrapped in a "user-wait" timing entry, so the summary can show +/// the turn with and without time spent waiting for the user. Everything else delegates. +/// +internal sealed class UserWaitTimingUi : IAgentUserInterface +{ + private readonly IAgentUserInterface _inner; + private readonly ITimingRecorder _timings; + + public UserWaitTimingUi(IAgentUserInterface inner, ITimingRecorder timings) + { + _inner = inner; + _timings = timings; + } + + public void Say(string message) => _inner.Say(message); + + public void ShowProgress(AgentProgressEvent progressEvent) => _inner.ShowProgress(progressEvent); + + public Task ConfirmAsync(ConfirmRequest request) + => TimedAsync(async () => await _inner.ConfirmAsync(request)); + + public Task AskAsync(string question) + => TimedAsync(async () => await _inner.AskAsync(question)); + + public Task ChooseAsync(ChoiceRequest request, CancellationToken cancellationToken = default) + => TimedAsync(async () => await _inner.ChooseAsync(request, cancellationToken)); + + public IDisposable BeginWait(string message) => _inner.BeginWait(message); + + public void ShowDiff(FileChange change) => _inner.ShowDiff(change); + + private async Task TimedAsync(Func> call) + { + _timings.Start(TimingRecorder.UserWaitPhase); + try + { + return await call(); + } + finally + { + _timings.Stop(TimingRecorder.UserWaitPhase); + } + } +} + diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/BrowserConnector.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/BrowserConnector.cs index 5f7735a..9bf5795 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/BrowserConnector.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/BrowserConnector.cs @@ -6,6 +6,7 @@ using ImanSoftware.KeylessAgent.Abstractions.Models.Enums; using ImanSoftware.Logging; using ImanSoftware.Outcomes; +using Microsoft.Extensions.Logging; using Microsoft.Playwright; namespace ImanSoftware.KeylessAgent.Core.Implementation.Browser; @@ -29,6 +30,12 @@ internal sealed class BrowserConnector : IBrowserConnector /// internal Func>>? OpenTabOverride { get; set; } + /// + /// When set (by the provider, from ChatSessionOptions.LogEvent), the launch command line + /// is forwarded here as a "chrome-launched" event with the full arguments payload. + /// + internal Func? LogEventSink { get; set; } + public BrowserConnector() : this(new BrowserEnvironment(), null) { @@ -117,6 +124,7 @@ internal BrowserConnector(IBrowserEnvironment environment, ILoggerAdapter 0 ? fallback.Pages[0] : await fallback.NewPageAsync(); + fallbackPage.SetDefaultTimeout(5_000); return Outcome.Successful((attachment, (object)fallbackPage)); } @@ -189,7 +197,28 @@ private async Task> LaunchRealChromeAsync( ui.ShowProgress(new AgentProgressEvent( AgentEventType.ConnectingToBrowser, $"Starting Chrome with the KeylessAgent profile ({userDataDir})…")); - var processId = await _environment.StartChromeAsync(chromePath, options.ChromeDebugPort, userDataDir, options.WindowMode, cancellationToken); + + // Screen diagnostics logged WITH the launch: raw monitor size vs work area (the + // difference is the taskbar). If the two ever stop differing by the taskbar's real + // height - DPI virtualization, auto-hide taskbar - it is visible in the turn log + // without reproducing anything manually. + var rawScreen = BrowserEnvironment.RawScreenSize(); + var workArea = BrowserEnvironment.PrimaryScreenSize(); + _log.LogInformation( + "Screen diagnostics: raw screen {RawWidth}x{RawHeight}, work area {WorkWidth}x{WorkHeight} (difference = taskbar).", + rawScreen.Width, rawScreen.Height, workArea.Width, workArea.Height); + + var processId = await _environment.StartChromeAsync(chromePath, options.ChromeDebugPort, userDataDir, options.WindowMode, cancellationToken, + onCommandLine: arguments => + { + _log.LogInformation("Chrome launched with command line: {Arguments}", arguments); + _ = LogEventSink?.Invoke("chrome-launched", new + { + Arguments = arguments, + RawScreenSize = $"{rawScreen.Width}x{rawScreen.Height}", + WorkAreaSize = $"{workArea.Width}x{workArea.Height}", + }); + }); // Wait until /json/version answers (30s), then attach. var deadline = DateTime.UtcNow.AddSeconds(30); @@ -201,6 +230,8 @@ private async Task> LaunchRealChromeAsync( var attach = await AttachOverCdpAsync(playwright, options, ui, cancellationToken); if (attach.IsSuccess) { + VerifyLaunchedWindowBounds(processId, userDataDir, options, workArea); + ui.ShowProgress(new AgentProgressEvent( AgentEventType.ConnectingToBrowser, "Started Chrome with the KeylessAgent profile. Log in to DeepSeek in that window once.")); @@ -263,8 +294,7 @@ private async Task> LaunchRealChromeAsync( // A browser-level CDP session opens a TAB in the existing window when one exists. var anchorPage = context.Pages.Count > 0 ? context.Pages[0] : await context.NewPageAsync(); var openSession = await context.NewCDPSessionAsync(anchorPage); - await openSession.SendAsync("Target.createTarget", - new Dictionary { ["url"] = ChatBaseUrl, ["newWindow"] = false }); + await openSession.SendAsync("Target.createTarget", ComposeCreateTargetParams(ChatBaseUrl)); await openSession.DetachAsync(); page = await WaitForPageAsync(context, ChatBaseUrl, cancellationToken); @@ -273,6 +303,11 @@ await openSession.SendAsync("Target.createTarget", AgentEventType.OpeningChat, "Opened a new DeepSeek tab.")); } + // Safety net: no Playwright call may ride the 30 s library default. Every hot call + // carries its own explicit timeout; this caps anything missed, on every connection + // path (attach, auto-launch, doctor). + page.SetDefaultTimeout(5_000); + // Normal operation: no focus, no restore — the window stays minimized. return Outcome.Successful((attachment, (object)page)); @@ -283,6 +318,90 @@ await openSession.SendAsync("Target.createTarget", } } + /// + /// Follow-up verification right after the launch attach: locate the launched Chrome's + /// real HWND and GetWindowRect it, logging the ACTUAL bounds next to the REQUESTED + /// --window-position/--window-size. Chrome can silently clamp or reposition a window + /// (e.g. enforce a minimum size) - a root cause distinct from work-area/DPI issues that + /// previously had zero visibility. Read-only: never moves or closes anything. + /// + private void VerifyLaunchedWindowBounds( + int launchedProcessId, string userDataDir, BrowserOptions options, (int Width, int Height) workArea) + { + if (OpenTabOverride is not null) + return; // test seam: no real Chrome exists to verify. + + try + { + var requested = BrowserEnvironment.RequestedWindowBounds(options.WindowMode, workArea); + if (requested is null) + return; // Normal never forces the window - nothing to verify. + + // The debug port can open before the window exists: poll briefly for the HWND. + IReadOnlyList handles = []; + for (var attempt = 0; attempt < 6 && handles.Count == 0; attempt++) + { + if (attempt > 0) + Thread.Sleep(500); + handles = ChromeWindowMover.FindOurChromeWindows(launchedProcessId, userDataDir, options.ChromeDebugPort); + } + + if (handles.Count == 0) + { + _log.LogWarning( + "Launched-window verification: no Chrome window of our chrome.exe processes was found (pid tree of {ProcessId}) within 3s of the launch.", + launchedProcessId); + return; + } + + // Multiple windows can exist (restored session): verify the LARGEST one - that is + // the main window the geometry request targeted. + var actual = handles + .Select(ChromeWindowMover.GetWindowBoundsWin32) + .OfType<(int Left, int Top, int Width, int Height)>() + .OrderByDescending(b => (long)b.Width * b.Height) + .FirstOrDefault(); + + var matches = Math.Abs(actual.Left - requested.Value.Left) <= 8 && + Math.Abs(actual.Top - requested.Value.Top) <= 8 && + Math.Abs(actual.Width - requested.Value.Width) <= 8 && + Math.Abs(actual.Height - requested.Value.Height) <= 8; + + var level = matches ? LogLevel.Information : LogLevel.Warning; + _log.Log(level, + "Launched-window verification ({Result}): requested {Requested}, actual {Actual} (mode {Mode}, tolerance 8px).", + matches ? "matches" : "MISMATCH", + $"{requested.Value.Left},{requested.Value.Top} {requested.Value.Width}x{requested.Value.Height}", + $"{actual.Left},{actual.Top} {actual.Width}x{actual.Height}", + options.WindowMode); + + _ = LogEventSink?.Invoke("chrome-window-bounds", new + { + Requested = $"{requested.Value.Left},{requested.Value.Top} {requested.Value.Width}x{requested.Value.Height}", + Actual = $"{actual.Left},{actual.Top} {actual.Width}x{actual.Height}", + Matches = matches, + Mode = options.WindowMode, + }); + } + catch (Exception ex) + { + // Verification is diagnostics, never a connection failure. + _log.LogWarning("Launched-window verification failed best-effort: {Message}", ex.Message); + } + } + + /// + /// The Target.createTarget parameters for the chat tab. background: true matters: + /// without it Chrome ACTIVATES the new tab, which restores a minimized window — the exact + /// "window pops up again" report. newWindow: false keeps it a tab in that window. + /// + internal static Dictionary ComposeCreateTargetParams(string url) => new() + { + ["url"] = url, + ["newWindow"] = false, + ["background"] = true, + }; + private static async Task WaitForPageAsync(IBrowserContext context, string urlPrefix, CancellationToken cancellationToken) { var deadline = DateTime.UtcNow.AddSeconds(15); diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/BrowserEnvironment.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/BrowserEnvironment.cs index df07ee4..d787225 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/BrowserEnvironment.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/BrowserEnvironment.cs @@ -26,9 +26,11 @@ internal interface IBrowserEnvironment /// /// Starts the real Chrome with the dedicated profile and the debugging port. /// - /// Minimized, Offscreen or Normal; unknown/empty defaults to Minimized. + /// Offscreen, Minimized (alias of offscreen), TinyCorner, EdgePeek or Normal. + /// Receives the FULL composed command line right before Process.Start + /// (diagnostics: 'chrome-launched'). /// The process id of the started Chrome. - Task StartChromeAsync(string chromePath, int port, string userDataDir, string windowMode, CancellationToken cancellationToken = default); + Task StartChromeAsync(string chromePath, int port, string userDataDir, string windowMode, CancellationToken cancellationToken = default, Action? onCommandLine = null); /// /// Connects over CDP to the Chrome on the debug port. Playwright-typed on the real @@ -42,6 +44,17 @@ internal interface IBrowserEnvironment /// internal sealed class BrowserEnvironment : IBrowserEnvironment { + private readonly ImanSoftware.Logging.ILoggerAdapter? _log; + + public BrowserEnvironment() : this(null) + { + } + + internal BrowserEnvironment(ImanSoftware.Logging.ILoggerAdapter? log) + { + _log = log; + } + public async Task IsDebugPortOpenAsync(int port, CancellationToken cancellationToken = default) { // A closed port must read as "port closed" (false), never as cancellation: HttpClient's @@ -116,12 +129,19 @@ public async Task IsDebugPortOpenAsync(int port, CancellationToken cancell return (browser, contexts[0]); } - public Task StartChromeAsync(string chromePath, int port, string userDataDir, string windowMode, CancellationToken cancellationToken = default) + public Task StartChromeAsync(string chromePath, int port, string userDataDir, string windowMode, CancellationToken cancellationToken = default, Action? onCommandLine = null) { + var arguments = ComposeChromeArguments(port, userDataDir, windowMode); + + // Diagnostics: the full command line BEFORE Process.Start, so a turn log can prove + // which anti-occlusion flags the launched Chrome actually received. + _log?.LogInformation("Chrome launch command line: {Arguments}", arguments); + onCommandLine?.Invoke(arguments); + var startInfo = new ProcessStartInfo { FileName = chromePath, - Arguments = ComposeChromeArguments(port, userDataDir, windowMode), + Arguments = arguments, UseShellExecute = true, }; @@ -130,26 +150,151 @@ public Task StartChromeAsync(string chromePath, int port, string userDataDi } /// - /// Maps the window mode to its Chrome flag: Minimized → --start-minimized, Offscreen → a - /// window position far off-screen, Normal → no flag (never forced focus). Unknown or empty - /// modes default to Minimized, matching the configuration default. + /// The primary screen's WORK AREA in pixels — the usable desktop excluding the taskbar + /// (the same width/height as System.Windows.Forms.Screen.PrimaryScreen.WorkingArea, + /// without pulling the WinForms dependency into Core). GetSystemMetrics(SM_CXSCREEN / + /// SM_CYSCREEN) would return the FULL monitor resolution and push the tiny corner + /// square UNDER the always-on-top taskbar. Falls back to 1920x1080 when unavailable. /// - internal static string WindowModeFlag(string windowMode) => windowMode.Trim().ToLowerInvariant() switch + internal static (int Width, int Height) PrimaryScreenSize() { - "normal" => "", - "offscreen" => "--window-position=-32000,-32000", - _ => "--start-minimized", - }; + if (OperatingSystem.IsWindows()) + { + try + { + // SPI_GETWORKAREA: the primary monitor's work area RECT (excludes the taskbar). + var area = new WorkAreaRect(); + if (SystemParametersInfo(SpiGetWorkArea, 0, ref area, 0) && + area.Right > area.Left && area.Bottom > area.Top) + { + return (area.Right - area.Left, area.Bottom - area.Top); + } + } + catch + { + // Fall through to the default. + } + } - internal static string ComposeChromeArguments(int port, string userDataDir, string windowMode) + return (1920, 1080); + } + + /// + /// The RAW primary screen size (SM_CXSCREEN/SM_CYSCREEN - the full monitor resolution, + /// taskbar included). Logged NEXT to the work area on every Chrome launch so a future + /// discrepancy (DPI virtualization, taskbar handling) is visible in turn logs instead of + /// requiring manual reproduction. + /// + internal static (int Width, int Height) RawScreenSize() { - var windowFlag = WindowModeFlag(windowMode); + if (OperatingSystem.IsWindows()) + { + try + { + var width = GetSystemMetrics(0); // SM_CXSCREEN + var height = GetSystemMetrics(1); // SM_CYSCREEN + if (width > 0 && height > 0) + return (width, height); + } + catch + { + // Fall through to the default. + } + } + + return (1920, 1080); + } + + private const uint SpiGetWorkArea = 0x0048; + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern int GetSystemMetrics(int nIndex); + + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] + private struct WorkAreaRect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [System.Runtime.InteropServices.DllImport("user32.dll")] + [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + private static extern bool SystemParametersInfo(uint uiAction, uint uiParam, ref WorkAreaRect pvParam, uint fWinIni); + + /// + /// The hide bounds: a VISIBLE-but-imperceptible 80x80 window in the bottom-right corner + /// of the primary screen's WORK AREA (taskbar excluded), so the square is actually + /// visible and never hides behind the taskbar. The fully offscreen (-32000,-32000) + /// position triggers Chrome's occlusion throttling (turn-17 truncated 20 times with the + /// occlusion flag present); a tiny on-screen window does not. Used by every hide path + /// and by WindowMode TinyCorner, so show/hide and launch all agree on one geometry. + /// The optional argument injects a work-area size (tests); null uses the real one. + /// + internal static (int Left, int Top, int Width, int Height) TinyCornerBounds((int Width, int Height)? screen = null) + { + var (width, height) = screen ?? PrimaryScreenSize(); + return (width - 80, height - 80, 80, 80); + } + + /// + /// Maps the window mode to its Chrome flags. Minimized and Offscreen place the window + /// fully offscreen; TinyCorner and EdgePeek keep a VISIBLE-but-tiny window in the + /// bottom-right corner of the WORK AREA (taskbar excluded — the square must be visible, + /// not behind the taskbar) — Chrome treats fully offscreen windows as occluded and + /// throttles the renderer regardless of the flags (turn-17/18), so a tiny on-screen + /// window is the reliable anti-throttling shape. "Minimized" is kept for backward + /// compatibility with existing keyless.json files but now means offscreen. Normal → no + /// flag (never forced focus). Unknown or empty modes default to offscreen. + /// + /// + /// The window bounds a WindowMode ASKS Chrome for at launch (null = Normal, never + /// forced). Used by the post-launch verification that compares the requested + /// position/size against the real GetWindowRect of the launched window. + /// + internal static (int Left, int Top, int Width, int Height)? RequestedWindowBounds(string windowMode, (int Width, int Height)? screen = null) + => windowMode.Trim().ToLowerInvariant() switch + { + "normal" => null, + "tinycorner" => TinyCornerBounds(screen), + "edgepeek" => screen is { } s + ? ((int Left, int Top, int Width, int Height)?)(s.Width - 10, s.Height - 10, 10, 10) + : null, + _ => (-32000, -32000, 1280, 900), + }; + + internal static string WindowModeFlag(string windowMode, int workAreaWidth, int workAreaHeight) + => windowMode.Trim().ToLowerInvariant() switch + { + "normal" => "", + "tinycorner" => $"--window-position={workAreaWidth - 80},{workAreaHeight - 80} --window-size=80,80", + "edgepeek" => $"--window-position={workAreaWidth - 10},{workAreaHeight - 10} --window-size=10,10", + // Minimized is kept for backward compatibility: it means offscreen as well. + _ => "--window-position=-32000,-32000 --window-size=1280,900", + }; + + internal static string ComposeChromeArguments(int port, string userDataDir, string windowMode, (int Width, int Height)? screen = null) + { + // `screen` is the WORK AREA (tests inject a fabricated one); null uses the real + // work area, so the launch flags and the CDP hide/show geometry always agree and + // both stay inside the visible desktop, clear of the taskbar. + var (screenWidth, screenHeight) = screen ?? PrimaryScreenSize(); + var windowFlag = WindowModeFlag(windowMode, screenWidth, screenHeight); var windowPrefix = windowFlag.Length > 0 ? windowFlag + " " : string.Empty; + + // NO URL is passed: Chrome 136+ ignores --start-minimized when a URL is present + // (the window must be visible to load it). The chat tab is opened via CDP after + // the attach, so the launched window can start minimized. return windowPrefix + $"--remote-debugging-port={port} --remote-debugging-address=127.0.0.1 " + $"--user-data-dir=\"{userDataDir}\" --no-first-run --no-default-browser-check " + "--disable-renderer-backgrounding --disable-background-timer-throttling " + "--disable-backgrounding-occluded-windows " + - "https://chat.deepseek.com/"; + // Windows 11 throttles a MINIMIZED window through native occlusion detection + // even with the flags above, which truncates the DeepSeek stream mid-reply: + // skip occlusion detection and intensive wake-up throttling at the feature level. + "--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling " + + "--disable-ipc-flooding-protection --disable-hang-monitor"; } } diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/ChatDiagnosticsService.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/ChatDiagnosticsService.cs index 96b20ea..469588f 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/ChatDiagnosticsService.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/ChatDiagnosticsService.cs @@ -1,6 +1,7 @@ using ImanSoftware.KeylessAgent.Abstractions.Models; using ImanSoftware.KeylessAgent.Abstractions.Models.Agent; using ImanSoftware.KeylessAgent.Abstractions.Services; +using ImanSoftware.Extensions; using ImanSoftware.Logging; using ImanSoftware.Outcomes; @@ -40,7 +41,8 @@ internal ChatDiagnosticsService( /// Keys that legitimately cannot exist in some page states. internal static readonly IReadOnlyList ConditionalKeys = [ - "StopIndicator", "AttachmentChips", "Reasoning", "FinalAnswer", "LoginIndicator", + "StopIndicator", "AttachmentChips", "AttachmentRemove", "AttachmentFailed", + "Reasoning", "FinalAnswer", "LoginIndicator", ]; /// @@ -163,7 +165,7 @@ internal static async Task BuildReportAsync( var checks = new List(); foreach (var (key, selectors) in SelectorEntries(catalog)) { - checks.Add(await ClassifyAsync(key, selectors, inspector, login, cancellationToken)); + checks.Add(await ClassifyAsync(key, selectors, inspector, catalog, login, cancellationToken)); } var deepThink = await inspector.GetToggleStateAsync(catalog.DeepThinkToggle, cancellationToken); @@ -210,12 +212,14 @@ internal static async Task BuildReportAsync( yield return ("DeepThinkToggle", catalog.DeepThinkToggle); yield return ("SearchToggle", catalog.SearchToggle); yield return ("AttachmentChips", catalog.AttachmentChips); + yield return ("AttachmentRemove", catalog.AttachmentRemove); + yield return ("AttachmentFailed", catalog.AttachmentFailed); yield return ("LoginIndicator", catalog.LoginIndicator); yield return ("ChatLimitNotice", catalog.ChatLimitNotice); } internal static async Task ClassifyAsync( - string key, string[] selectors, IChatPageInspector inspector, + string key, string[] selectors, IChatPageInspector inspector, SelectorsCatalog catalog, (int Index, int Count)? login, CancellationToken cancellationToken) { if (key == "LoginIndicator") @@ -227,6 +231,43 @@ internal static async Task ClassifyAsync( "Not on the login page (the session looks logged in)."); } + // The two attachment-chip repair keys: they only matter when a chip is actually + // visible. Without chips they are legitimately not applicable; WITH chips a miss is + // a real problem, so the doctor prints the closest chip candidate (trimmed HTML) to + // fix the selectors from facts. + if (key is "AttachmentRemove" or "AttachmentFailed") + { + var own = await inspector.ProbeAsync(selectors, cancellationToken); + if (own is not null) + { + var ownDetail = key == "AttachmentRemove" + ? $"The chip's remove control matched (fallback #{own.Value.Index})." + : "A failed/error chip marker matched (the upload did not land)."; + return new SelectorCheck(key, SelectorCheckStatus.Matched, own.Value.Index, own.Value.Count, ownDetail); + } + + var chip = await inspector.ProbeAsync(catalog.AttachmentChips, cancellationToken); + if (chip is null) + { + var composer = await inspector.CaptureTrimmedAsync(catalog.InputBox, cancellationToken); + var composerNote = string.IsNullOrWhiteSpace(composer) + ? string.Empty + : $" Closest candidate (composer, trimmed): {composer.Truncate(300)}"; + return new SelectorCheck(key, SelectorCheckStatus.NotApplicableNow, -1, 0, + "No attachment chips are visible right now (nothing to remove)." + composerNote); + } + + var candidate = await inspector.CaptureTrimmedAsync(catalog.AttachmentChips, cancellationToken); + var candidateNote = string.IsNullOrWhiteSpace(candidate) + ? string.Empty + : $" Closest chip candidate (trimmed): {candidate.Truncate(300)}"; + + return new SelectorCheck(key, SelectorCheckStatus.NotFound, -1, 0, + (key == "AttachmentRemove" + ? "A chip is visible but its remove control did not match." + : "A chip is visible but no failed/error marker matched.") + candidateNote); + } + var probe = await inspector.ProbeAsync(selectors, cancellationToken); if (probe is not null) { @@ -234,21 +275,47 @@ internal static async Task ClassifyAsync( { "FinalAnswer" => "Final-answer element exists (a reply is present or in progress).", "Reasoning" => "A reasoning block exists (a thinking message is present).", - "StopIndicator" => "Generating is in progress.", + "StopIndicator" => $"Generating is in progress (fallback #{probe.Value.Index}).", + "MessageCopyButton" => $"Message-level Copy control exists (fallback #{probe.Value.Index}; hover the reply shows it).", "AttachmentChips" => "Attachment chips are visible (files attached).", - "MessageCopyButton" => "Message-level Copy control exists (hover the reply shows it).", "ChatLimitNotice" => "The chat has reached its length limit (exhausted).", - _ => "Selector matched.", + _ => $"Selector matched (fallback #{probe.Value.Index}).", }; return new SelectorCheck(key, SelectorCheckStatus.Matched, probe.Value.Index, probe.Value.Count, detail); } + // For the two hover/generating-state controls, a NOT-MATCHED result is only useful + // with evidence: include the trimmed HTML of the closest candidate (the newest + // assistant message) so a changed web UI can be diagnosed from the doctor output. + if (key is "MessageCopyButton" or "StopIndicator") + { + var candidate = await inspector.CaptureTrimmedAsync(catalog.AssistantMessage, cancellationToken); + var candidateNote = string.IsNullOrWhiteSpace(candidate) + ? string.Empty + : $" Closest candidate (newest message, trimmed): {candidate.Truncate(300)}"; + + var notApplicableNow = key == "MessageCopyButton" + ? "No assistant message is visible — the Copy button appears only on a reply." + : "Nothing is generating right now."; + + // The Copy button is the highest-value selector: when an assistant message IS + // visible but the button did not match, dump every real button candidate with + // its actual aria-label and class names. + if (key == "MessageCopyButton") + { + var buttons = await inspector.DumpButtonsAsync(catalog.AssistantMessage, cancellationToken); + candidateNote += buttons.Length > 0 + ? $" Buttons on the newest message: {buttons.Truncate(400)}" + : string.Empty; + } + + return new SelectorCheck(key, SelectorCheckStatus.NotApplicableNow, -1, 0, notApplicableNow + candidateNote); + } + // Nothing matched: is that legitimate for the current state? var notApplicable = key switch { - "StopIndicator" => "Nothing is generating right now.", "AttachmentChips" => "Nothing is attached right now.", - "MessageCopyButton" => "No assistant message is visible — the Copy button appears only on a reply.", "Reasoning" => "No message with a reasoning block is visible right now.", "FinalAnswer" => "No assistant message is visible right now (new chat).", "LoginIndicator" => "Not on the login page.", @@ -256,9 +323,21 @@ internal static async Task ClassifyAsync( _ => string.Empty, }; - return notApplicable.Length > 0 - ? new SelectorCheck(key, SelectorCheckStatus.NotApplicableNow, -1, 0, notApplicable) - : new SelectorCheck(key, SelectorCheckStatus.NotFound, -1, 0, "No fallback selector matched — this key is required and must be fixed."); + if (notApplicable.Length > 0) + { + // The chip keys report against the COMPOSER: when they match nothing, print the + // trimmed composer-area HTML so unverified selectors can be fixed from facts. + var composerCandidate = key is "AttachmentChips" or "AttachmentRemove" or "AttachmentFailed" + ? await inspector.CaptureTrimmedAsync(catalog.InputBox, cancellationToken) + : null; + var composerNote = string.IsNullOrWhiteSpace(composerCandidate) + ? string.Empty + : $" Closest candidate (composer, trimmed): {composerCandidate.Truncate(300)}"; + + return new SelectorCheck(key, SelectorCheckStatus.NotApplicableNow, -1, 0, notApplicable + composerNote); + } + + return new SelectorCheck(key, SelectorCheckStatus.NotFound, -1, 0, "No fallback selector matched — this key is required and must be fixed."); } internal static async Task> CaptureAsync( diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/ChromeWindowController.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/ChromeWindowController.cs new file mode 100644 index 0000000..e3bad24 --- /dev/null +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/ChromeWindowController.cs @@ -0,0 +1,373 @@ +using ImanSoftware.KeylessAgent.Abstractions.Models; +using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration; +using System.Diagnostics; +using ImanSoftware.Logging; +using ImanSoftware.Outcomes; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging; +using Microsoft.Playwright; + +namespace ImanSoftware.KeylessAgent.Core.Implementation.Browser; + +/// +/// Brings the (offscreen or minimized) Chrome window into view on demand — or puts it back — +/// through pure CDP window-bounds calls. Used by keyless show/keyless hide, the +/// doctor page's Show button, the keyless chrome command and the login flow. No +/// automation, no page navigation: a window move only, so a running turn is never disturbed. +/// +public interface IChromeWindowController +{ + /// + /// Shows the chat window at (100,100) 1280x900 and focuses it. + /// of 0 keeps it visible until or the user closes it; a positive + /// value returns the window offscreen after that many seconds. + /// + Task ShowAsync(BrowserOptions options, int peekSeconds, CancellationToken ct = default); + + /// Moves the chat window offscreen (-32000,-32000), still "normal" so Chrome keeps rendering. + Task HideAsync(BrowserOptions options, CancellationToken ct = default); + + /// + /// CLOSES the KeylessAgent Chrome entirely (all tabs, the whole process tree) so the + /// invisible tiny-corner window cannot outlive the session: a graceful CDP Browser.close + /// first (flushes the profile), the debug port polled to confirm it went down, then - + /// only if that failed - a process-tree kill of every chrome.exe whose command line + /// carries OUR profile directory. A Chrome matched only by the debug port (started by + /// the USER on our port) is refused unless - it is the + /// user's own browser, matching the AttachedToRunningChrome ownership rule. + /// + /// Carries ChromeDebugPort and the profile dir - ownership is + /// matched from the RUNNING processes' command lines, so no session-scoped PID is needed + /// (works after an app restart). + /// Also close a Chrome that merely sits on our debug port + /// without the KeylessAgent profile. Default false (refuse). + Task CloseAsync(BrowserOptions options, bool allowAttached = false, CancellationToken ct = default); +} + +/// CDP-backed implementation: connect over the debug port, resolve the window, move it. +internal sealed class ChromeWindowController : IChromeWindowController +{ + private readonly IBrowserEnvironment _environment; + private readonly ILoggerAdapter _log; + + /// Test seam: replaces Playwright.CreateAsync (which spawns the driver). + internal Func> PlaywrightFactory { get; set; } = + ct => Playwright.CreateAsync(); + + public ChromeWindowController(IBrowserEnvironment? environment = null, ILoggerFactory? loggerFactory = null) + { + _environment = environment ?? new BrowserEnvironment(); + _log = new LoggerAdapter(loggerFactory ?? NullLoggerFactory.Instance); + } + + /// + public async Task ShowAsync(BrowserOptions options, int peekSeconds, CancellationToken ct = default) + { + // Same geometry as HideAsync: the peek's return-to-hidden lands in the tiny corner. + var hideBounds = BrowserEnvironment.TinyCornerBounds(); + var showBounds = (Left: 100, Top: 100, Width: 1280, Height: 900); + var info = (Action)(message => _log.LogInformation("{Message}", message)); + var debug = (Action)(message => _log.LogDebug("{Message}", message)); + + var cdpVerified = false; + try + { + if (!await _environment.IsDebugPortOpenAsync(options.ChromeDebugPort, ct)) + return PortClosed(options); + + var playwright = await PlaywrightFactory(ct); + var browser = await ConnectAsync(playwright, options); + var context = browser.Contexts.FirstOrDefault() + ?? throw new InvalidOperationException( + "The Chrome on the debug port exposes no browser context (is it still starting?)."); + // With several tabs open the FIRST page is often not the chat: prefer the page + // whose URL is the chat host so the CHAT window is the one that gets moved. + var page = ChromeWindowMover.PickChatPage(context) ?? await context.NewPageAsync(); + debug($"Chat window picked from page: {page.Url}"); + + var session = await page.Context.NewCDPSessionAsync(page); + try + { + var windowId = await ResolveWindowIdAsync(session, ct); + info($"CDP Show: Browser.setWindowBounds(windowId {windowId}, bounds {{left: {showBounds.Left}, top: {showBounds.Top}, width: {showBounds.Width}, height: {showBounds.Height}, windowState: normal}})"); + cdpVerified = await ChromeWindowMover.TryMoveViaCdpAsync(session, windowId, showBounds, "Show", info, debug); + if (!cdpVerified) + return await RecoverViaWin32OrFailAsync(options, showBounds, "Show", debug); + + var targetId = await ResolveTargetIdAsync(session, ct); + info($"CDP Show: Target.activateTarget(targetId {targetId})"); + await session.SendAsync("Target.activateTarget", new Dictionary + { + ["targetId"] = targetId, + }); + + if (peekSeconds > 0) + { + await Task.Delay(peekSeconds * 1000, ct); + info($"CDP Hide: Browser.setWindowBounds(windowId {windowId}, bounds {{left: {hideBounds.Left}, top: {hideBounds.Top}, width: {hideBounds.Width}, height: {hideBounds.Height}, windowState: normal}})"); + // The peek's return is best-effort: the SHOW already succeeded. + await ChromeWindowMover.TryMoveViaCdpAsync(session, windowId, hideBounds, "Hide", info, debug); + } + + return Outcome.Successful(); + } + finally + { + await session.DetachAsync(); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + debug($"CDP show failed: {ex.Message}; falling back to Win32."); + } + + if (!cdpVerified) + return await RecoverViaWin32OrFailAsync(options, showBounds, "Show", debug); + return Outcome.Successful(); + } + + /// + public async Task HideAsync(BrowserOptions options, CancellationToken ct = default) + { + // The hide geometry: a tiny 80x80 square in the screen's WORK AREA bottom-right + // corner - NOT fully offscreen, which Chrome treats as occluded and throttles + // (turn-17/18). Not hidden behind the taskbar either (SPI_GETWORKAREA). + var hideBounds = BrowserEnvironment.TinyCornerBounds(); + var info = (Action)(message => _log.LogInformation("{Message}", message)); + var debug = (Action)(message => _log.LogDebug("{Message}", message)); + + var cdpVerified = false; + try + { + if (!await _environment.IsDebugPortOpenAsync(options.ChromeDebugPort, ct)) + return PortClosed(options); + + var playwright = await PlaywrightFactory(ct); + var browser = await ConnectAsync(playwright, options); + var context = browser.Contexts.FirstOrDefault() + ?? throw new InvalidOperationException( + "The Chrome on the debug port exposes no browser context (is it still starting?)."); + var page = ChromeWindowMover.PickChatPage(context) ?? await context.NewPageAsync(); + debug($"Chat window picked from page: {page.Url}"); + + var session = await page.Context.NewCDPSessionAsync(page); + try + { + var windowId = await ResolveWindowIdAsync(session, ct); + info($"CDP Hide: Browser.setWindowBounds(windowId {windowId}, bounds {{left: {hideBounds.Left}, top: {hideBounds.Top}, width: {hideBounds.Width}, height: {hideBounds.Height}, windowState: normal}})"); + cdpVerified = await ChromeWindowMover.TryMoveViaCdpAsync(session, windowId, hideBounds, "Hide", info, debug); + } + finally + { + await session.DetachAsync(); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + debug($"CDP hide failed: {ex.Message}; falling back to Win32."); + } + + if (!cdpVerified) + return await RecoverViaWin32OrFailAsync(options, hideBounds, "Hide", debug); + return Outcome.Successful(); + } + + /// + /// The shared tail of both directions: when CDP could not verify the move, try the Win32 + /// fallback (show: SW_SHOWNOACTIVATE + visible/not-minimized read-back; hide: SW_HIDE + + /// invisible read-back) - our processes are matched by profile directory and debug port. + /// When even the fallback cannot verify anything, FAIL honestly instead of reporting a + /// move that never happened. + /// + private async Task RecoverViaWin32OrFailAsync( + BrowserOptions options, (int Left, int Top, int Width, int Height) bounds, string label, Action debug) + { + debug($"CDP {label} was not verified; falling back to Win32."); + var win32Verified = ChromeWindowMover.ShowWindowsViaWin32( + launchedProcessId: 0, + BrowserConnector.ResolveUserDataDir(options), + options.ChromeDebugPort, + label == "Show" ? ChromeWindowMover.SwShownoactivate : ChromeWindowMover.SwHide, + hwnd => label == "Show" + ? ChromeWindowMover.IsWindowVisibleWin32(hwnd) && !ChromeWindowMover.IsIconicWin32(hwnd) + : !ChromeWindowMover.IsWindowVisibleWin32(hwnd), + label, + debug); + return win32Verified + ? Outcome.Successful() + : Outcome.Failure(new OutcomeError( + "Chrome window could not be located or moved - it may have been closed.", + ErrorCodes.BrowserConnectionFailed, OutcomeErrorType.Problem)); + } + + /// + public async Task CloseAsync(BrowserOptions options, bool allowAttached = false, CancellationToken ct = default) + { + var debugPort = options.ChromeDebugPort; + var userDataDir = BrowserConnector.ResolveUserDataDir(options); + var info = (Action)(message => _log.LogInformation("{Message}", message)); + var debug = (Action)(message => _log.LogDebug("{Message}", message)); + + // --- Ownership: match the RUNNING processes' command lines, never a session PID --- + var processInfos = ProcessInfosOverride is not null + ? ProcessInfosOverride() + : ChromeWindowMover.GetChromeProcessInfos(); + var oursByProfile = processInfos + .Where(p => p.CommandLine is not null && p.CommandLine.Contains(userDataDir, StringComparison.OrdinalIgnoreCase)) + .Select(p => p.Pid) + .ToList(); + var oursByPortOnly = processInfos + .Where(p => p.CommandLine is not null + && p.CommandLine.Contains($"--remote-debugging-port={debugPort}", StringComparison.OrdinalIgnoreCase) + && (p.CommandLine is null || !p.CommandLine.Contains(userDataDir, StringComparison.OrdinalIgnoreCase))) + .Select(p => p.Pid) + .Except(oursByProfile) + .ToList(); + + if (oursByProfile.Count == 0 && oursByPortOnly.Count == 0) + { + var portOpen = await _environment.IsDebugPortOpenAsync(debugPort, ct); + return Outcome.Failure(new OutcomeError( + portOpen + ? $"Chrome is running on port {debugPort} but it does not carry the KeylessAgent profile and was not launched by KeylessAgent - nothing was closed." + : $"Chrome is not running on port {debugPort} - nothing to close.", + ErrorCodes.BrowserConnectionFailed, OutcomeErrorType.Problem)); + } + + if (oursByProfile.Count == 0 && !allowAttached) + { + // The ownership rule of DisposeAsync/AttachedToRunningChrome: a Chrome sitting on + // our port without OUR profile is the user's own browser - never silently killed. + return Outcome.Failure(new OutcomeError( + $"The Chrome on port {debugPort} was not launched with the KeylessAgent profile, so it looks like your own browser. It was NOT closed.", + ErrorCodes.BrowserConnectionFailed, OutcomeErrorType.Problem)); + } + + var killTargets = oursByProfile.ToList(); + if (allowAttached) + killTargets.AddRange(oursByPortOnly); + + // --- 1. Graceful: CDP Browser.close (flushes the profile, closes every tab) --- + try + { + if (await _environment.IsDebugPortOpenAsync(debugPort, ct)) + { + var playwright = await PlaywrightFactory(ct); + var browser = await ConnectAsync(playwright, options); + var context = browser.Contexts.FirstOrDefault(); + if (context is not null) + { + var page = ChromeWindowMover.PickChatPage(context) ?? await context.NewPageAsync(); + var session = await page.Context.NewCDPSessionAsync(page); + try + { + info("CDP Close: Browser.close (graceful, whole browser)."); + await session.SendAsync("Browser.close", new Dictionary()); + } + finally + { + try { await session.DetachAsync(); } + catch { /* the browser may already be gone - that is the goal */ } + } + } + } + } + catch (Exception ex) + { + debug($"CDP close failed: {ex.Message}; falling back to process termination."); + } + + // --- 2. Confirm the port actually went down (a few seconds) --- + for (var attempt = 0; attempt < 10; attempt++) + { + if (!await _environment.IsDebugPortOpenAsync(debugPort, ct)) + { + info("Chrome closed: the debug port is down (graceful close confirmed)."); + return Outcome.Successful(); + } + + await Task.Delay(300, ct); + } + + // --- 3. Fallback: kill the whole process tree of every owned root --- + debug("The debug port stayed open after Browser.close; killing the owned process trees."); + foreach (var pid in killTargets.Distinct()) + { + try + { + if (KillOverride is not null) + { + KillOverride(pid); // test seam: no real process is ever killed from a unit test + continue; + } + + using var process = Process.GetProcessById(pid); + if (process is null || process.HasExited) + continue; + info($"Killing chrome.exe process tree (pid {pid})."); + process.Kill(entireProcessTree: true); + } + catch (Exception ex) + { + debug($"Kill of pid {pid} skipped: {ex.Message}"); // already exited - fine + } + } + + for (var attempt = 0; attempt < 10; attempt++) + { + var anyLeft = (ProcessInfosOverride is not null ? ProcessInfosOverride() : ChromeWindowMover.GetChromeProcessInfos()) + .Any(p => killTargets.Contains(p.Pid)); + if (!await _environment.IsDebugPortOpenAsync(debugPort, ct) && !anyLeft) + { + info("Chrome closed: the process tree is gone."); + return Outcome.Successful(); + } + + await Task.Delay(300, ct); + } + + return Outcome.Failure(new OutcomeError( + $"Chrome could not be closed on port {debugPort} - the process tree survived the graceful close and the kill. Close it manually.", + ErrorCodes.BrowserConnectionFailed, OutcomeErrorType.Failure)); + } + + /// Test seam: replaces the real chrome.exe enumeration (unit tests must never touch live processes). + internal Func>? ProcessInfosOverride { get; set; } + + /// Test seam: replaces Process.Kill (unit tests must never kill real processes). + internal Action? KillOverride { get; set; } + + private async Task ConnectAsync(IPlaywright playwright, BrowserOptions options) + => await playwright.Chromium.ConnectOverCDPAsync( + $"http://127.0.0.1:{options.ChromeDebugPort}", + new BrowserTypeConnectOverCDPOptions { Timeout = 10_000 }); + + /// The window that owns the resolved chat page; one CDP round trip. + private static async Task ResolveWindowIdAsync(ICDPSession session, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var response = await session.SendAsync("Browser.getWindowForTarget"); + return ((System.Text.Json.JsonElement)response).GetProperty("windowId").GetInt64(); + } + + private static async Task ResolveTargetIdAsync(ICDPSession session, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + var response = await session.SendAsync("Target.getTargetInfo"); + return ((System.Text.Json.JsonElement)response) + .GetProperty("targetInfo").GetProperty("targetId").GetString() ?? string.Empty; + } + + private static Outcome PortClosed(BrowserOptions options) + => Outcome.Failure(new OutcomeError( + $"Chrome is not running on port {options.ChromeDebugPort}. Run 'keyless chrome' first.", + ErrorCodes.BrowserConnectionFailed, OutcomeErrorType.Problem)); +} diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/ChromeWindowMinimizer.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/ChromeWindowMinimizer.cs new file mode 100644 index 0000000..44851e8 --- /dev/null +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/ChromeWindowMinimizer.cs @@ -0,0 +1,84 @@ +using ImanSoftware.Logging; +using Microsoft.Playwright; + +namespace ImanSoftware.KeylessAgent.Core.Implementation.Browser; + +/// +/// Keeps the chat window out of the user's way WITHOUT throttling Chrome, during a turn: +/// the window is MOVED to a VISIBLE-but-imperceptible 80x80 square in the bottom-right +/// corner of the primary screen's WORK AREA (windowState "normal"). The fully offscreen +/// (-32000,-32000) position turned out to be OCCLUDED in Chrome's eyes — the renderer +/// throttled and the DeepSeek stream truncated even with the occlusion feature disabled +/// (turn-17: 20 truncations; turn-18: visible window succeeded, hidden again truncated) — +/// so the hide geometry is tiny-but-on-screen. A truly minimized window is equally +/// throttled and never used. +/// The move mechanics (CDP read-back verification + the Win32 fallback for both directions) +/// live in ; this class only orchestrates them with the +/// five-second CDP race. Recover the window with 'keyless show' or the WPF Show button. +/// +internal static class ChromeWindowMinimizer +{ + internal static async Task HideAsync( + IPage page, int launchedProcessId, string userDataDir, int debugPort, + ILoggerAdapter? log, CancellationToken cancellationToken) + { + var bounds = BrowserEnvironment.TinyCornerBounds(); + var info = log is null ? (Action?)null : message => log.LogInformation("{Message}", message); + var debug = log is null ? (Action?)null : message => log.LogDebug("{Message}", message); + + // The CDP block gets its own five-second window: a hung CDP call falls through to + // the Win32 fallback instead of running until the caller's cancellation. + using var cdpCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cdpCts.CancelAfter(TimeSpan.FromSeconds(5)); + var verified = false; + try + { + cancellationToken.ThrowIfCancellationRequested(); + var session = await page.Context.NewCDPSessionAsync(page); + try + { + // ICDPSession.SendAsync has no CancellationToken overload, so the whole CDP + // exchange is raced against the five-second window; a hung call loses the + // race and the Win32 fallback takes over. + var cdpWork = Task.Run(async () => + { + var window = await session.SendAsync("Browser.getWindowForTarget"); + var windowId = ((System.Text.Json.JsonElement)window).GetProperty("windowId").GetInt64(); + return await ChromeWindowMover.TryMoveViaCdpAsync(session, windowId, bounds, "Hide", info, debug); + }, cdpCts.Token); + + var completed = await Task.WhenAny(cdpWork, Task.Delay(Timeout.Infinite, cdpCts.Token)); + if (completed != cdpWork) + throw new OperationCanceledException(cdpCts.Token); + + verified = await cdpWork; + if (verified) + return; // CDP confirmed the window sits in the tiny corner. + } + catch (OperationCanceledException) when (cdpCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + // fall through to the Win32 fallback + } + finally + { + await session.DetachAsync(); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + debug?.Invoke($"CDP hide failed: {ex.Message}; falling back to Win32."); + } + + if (!verified) + debug?.Invoke("CDP hide was not verified; falling back to Win32."); + + // Best-effort, as before: the turn never fails because the window could not be moved. + ChromeWindowMover.ShowWindowsViaWin32( + launchedProcessId, userDataDir, debugPort, ChromeWindowMover.SwHide, + hwnd => !ChromeWindowMover.IsWindowVisibleWin32(hwnd), "Hide", debug); + } +} diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/ChromeWindowMover.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/ChromeWindowMover.cs new file mode 100644 index 0000000..5a8ff18 --- /dev/null +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/ChromeWindowMover.cs @@ -0,0 +1,458 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.Playwright; + +namespace ImanSoftware.KeylessAgent.Core.Implementation.Browser; + +/// +/// The ONE shared window-move engine, used by both ChromeWindowMinimizer (during a turn) +/// and ChromeWindowController (keyless show/hide, WPF buttons, login flow): +/// 1. CDP move with READ-BACK VERIFICATION: Browser.setWindowBounds, then Browser.getWindowBounds +/// must report exactly the target bounds and windowState — with a forced-normal recovery +/// between attempts, up to 3 tries. True = verified; false = CDP could not confirm. +/// 2. Win32 fallback for BOTH directions: enumerate the top-level Chrome windows of OUR +/// chrome.exe processes (the launched PID's descendant tree, or — when attached — the +/// processes whose command line carries our --user-data-dir / --remote-debugging-port) +/// and ShowWindow them, then verify the expected visibility state by reading the windows +/// back (IsWindowVisible / IsIconic). The launcher PID often has MainWindowHandle == 0, +/// which is why the naive fallback missed. +/// 3. Chat-window picking: prefer the page whose URL is the chat host, so with several tabs +/// open the WINDOW THAT OWNS THE CHAT TAB is the one that gets moved. +/// Logging flows through simple delegates so both call sites keep their own typed loggers. +/// +internal static class ChromeWindowMover +{ + internal const int SwHide = 0; + internal const int SwShownoactivate = 4; + internal const int SwRestore = 9; + + /// Test seam: replaces the Win32 pass (so unit tests never touch real windows). + internal static Func, string, Action?, bool>? Win32ShowWindowsOverride { get; set; } + + /// + /// One CDP move + read-back verification loop: setWindowBounds, then getWindowBounds must + /// match the target exactly; on mismatch force normal first and retry, up to 3 attempts. + /// + internal static async Task TryMoveViaCdpAsync( + ICDPSession session, long windowId, (int Left, int Top, int Width, int Height) bounds, string label, + Action? info = null, Action? debug = null) + { + for (var attempt = 1; attempt <= 3; attempt++) + { + await session.SendAsync("Browser.setWindowBounds", new Dictionary + { + ["windowId"] = windowId, + ["bounds"] = new Dictionary + { + ["left"] = bounds.Left, + ["top"] = bounds.Top, + ["width"] = bounds.Width, + ["height"] = bounds.Height, + ["windowState"] = "normal", + }, + }); + + if (await ReadBackMatchesAsync(session, windowId, bounds)) + { + debug?.Invoke($"CDP {label}: read-back verified (attempt {attempt})."); + return true; + } + + // Not verified: force NORMAL first (clears the bounds transition), then retry. + debug?.Invoke($"CDP {label}: read-back mismatch (attempt {attempt}); forcing normal and retrying."); + await SetWindowStateAsync(session, windowId, "normal"); + await session.SendAsync("Browser.setWindowBounds", new Dictionary + { + ["windowId"] = windowId, + ["bounds"] = new Dictionary + { + ["left"] = bounds.Left, + ["top"] = bounds.Top, + ["width"] = bounds.Width, + ["height"] = bounds.Height, + ["windowState"] = "normal", + }, + }); + + if (await ReadBackMatchesAsync(session, windowId, bounds)) + { + debug?.Invoke($"CDP {label}: read-back verified after the recovery (attempt {attempt})."); + return true; + } + + await Task.Delay(300); + } + + return false; + } + + private static async Task SetWindowStateAsync(ICDPSession session, long windowId, string windowState) + => await session.SendAsync("Browser.setWindowBounds", new Dictionary + { + ["windowId"] = windowId, + ["bounds"] = new Dictionary { ["windowState"] = windowState }, + }); + + /// True when the read-back bounds match the target exactly, windowState "normal". + private static async Task ReadBackMatchesAsync(ICDPSession session, long windowId, (int Left, int Top, int Width, int Height) bounds) + { + var response = await session.SendAsync("Browser.getWindowBounds", + new Dictionary { ["windowId"] = windowId }); + var element = (System.Text.Json.JsonElement)response; + if (!element.TryGetProperty("bounds", out var shape)) + return false; + return shape.TryGetProperty("windowState", out var state) && + string.Equals(state.GetString(), "normal", StringComparison.OrdinalIgnoreCase) && + shape.TryGetProperty("left", out var left) && left.GetInt32() == bounds.Left && + shape.TryGetProperty("top", out var top) && top.GetInt32() == bounds.Top && + shape.TryGetProperty("width", out var width) && width.GetInt32() == bounds.Width && + shape.TryGetProperty("height", out var height) && height.GetInt32() == bounds.Height; + } + + /// + /// The Win32 pass for BOTH directions: ShowWindow our Chrome windows with + /// , then verify at least one window reached the expected + /// state () within ~500 ms. True = verified. + /// + internal static bool ShowWindowsViaWin32( + int launchedProcessId, string userDataDir, int debugPort, int showCommand, + Func verifyWindow, string label, Action? debug = null) + { + if (Win32ShowWindowsOverride is not null) + return Win32ShowWindowsOverride(launchedProcessId, userDataDir, showCommand, verifyWindow, label, debug); + + if (!OperatingSystem.IsWindows()) + return false; + + try + { + var handles = FindChromeWindowHandles(launchedProcessId, userDataDir, debugPort); + if (handles.Count == 0) + { + debug?.Invoke($"Win32 {label}: no Chrome window of our chrome.exe processes was found."); + return false; + } + + foreach (var handle in handles) + ShowWindow(handle, showCommand); + + // Read-back: the visibility state must actually flip, not just be commanded. + var verified = false; + for (var attempt = 0; attempt < 5 && !verified; attempt++) + { + Thread.Sleep(100); + verified = handles.Any(verifyWindow); + } + + debug?.Invoke($"Win32 {label}: ShowWindow sent to {handles.Count} Chrome window(s); verified: {verified}."); + return verified; + } + catch (Exception ex) + { + debug?.Invoke($"Win32 {label} failed: {ex.Message}"); + return false; + } + } + + /// + /// The window to move: prefer the page whose URL is the chat host (with several tabs open + /// the FIRST page is often not the chat), falling back to the first page, else null. + /// + internal static IPage? PickChatPage(IBrowserContext context) + => context.Pages.FirstOrDefault(p => p.Url.Contains("chat.deepseek.com", StringComparison.OrdinalIgnoreCase)) + ?? context.Pages.FirstOrDefault(); + + /// + /// Internal wrapper over the enumeration, for post-launch verification: the real HWNDs of + /// our chrome.exe windows, so the caller can GetWindowRect them and compare against the + /// requested launch geometry. + /// + internal static IReadOnlyList FindOurChromeWindows(int launchedProcessId, string userDataDir, int debugPort) + => FindChromeWindowHandles(launchedProcessId, userDataDir, debugPort); + + /// All visible top-level Chrome windows (class Chrome_WidgetWin_1) of our processes. + private static IReadOnlyList FindChromeWindowHandles(int launchedProcessId, string userDataDir, int debugPort) + { + var ours = CollectOurChromeProcessIds(launchedProcessId, userDataDir, debugPort); + var handles = new List(); + if (ours.Count == 0) + return handles; + + EnumWindows((hwnd, _) => + { + if (!IsWindowVisibleWin32(hwnd)) + return true; + if (!ChromeClassName(GetWindowClassName(hwnd))) + return true; + if (GetWindowThreadProcessId(hwnd, out var pid) == 0) + return true; + if (!ours.Contains((int)pid)) + return true; + handles.Add(hwnd); + return true; + }, IntPtr.Zero); + return handles; + } + + private static bool ChromeClassName(string className) + => string.Equals(className, "Chrome_WidgetWin_1", StringComparison.Ordinal); + + /// + /// A snapshot of every RUNNING chrome.exe process with its parent pid and command line + /// (read through the PEB; null when access is denied). Shared by the ownership matching + /// () and the window-close ownership check. + /// + internal static List<(int Pid, int ParentPid, string? CommandLine)> GetChromeProcessInfos() + { + var chromeProcesses = new List<(int Pid, int ParentPid, string? CommandLine)>(); + foreach (var process in Process.GetProcessesByName("chrome")) + { + try + { + chromeProcesses.Add((process.Id, GetParentProcessId(process.Id), TryReadCommandLine(process.Id))); + } + catch + { + // A dying process between enumeration and query is normal. + } + finally + { + process.Dispose(); + } + } + + return chromeProcesses; + } + + /// + /// The chrome.exe processes that belong to THIS agent: the launched PID's descendant + /// tree, plus — for the attached case, where no PID is known — the processes whose + /// command line carries our debug port or profile directory. + /// + internal static HashSet CollectOurChromeProcessIds(int launchedProcessId, string userDataDir, int debugPort) + { + var ours = new HashSet(); + try + { + var chromeProcesses = GetChromeProcessInfos(); + + if (launchedProcessId > 0) + { + // Descendant tree of the launched launcher (the browser process we started). + var children = chromeProcesses + .Where(p => p.ParentPid > 0) + .ToLookup(p => p.ParentPid, p => p.Pid); + var queue = new Queue(); + queue.Enqueue(launchedProcessId); + ours.Add(launchedProcessId); + while (queue.Count > 0) + { + foreach (var child in children[queue.Dequeue()]) + if (ours.Add(child)) + queue.Enqueue(child); + } + } + + foreach (var (pid, _, commandLine) in chromeProcesses) + { + if (commandLine is null) + continue; + if (commandLine.Contains($"--remote-debugging-port={debugPort}", StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrEmpty(userDataDir) && + commandLine.Contains(userDataDir, StringComparison.OrdinalIgnoreCase))) + { + ours.Add(pid); + } + } + } + catch + { + // Best-effort: returning what we have is always acceptable. + } + + return ours; + } + + private static string GetWindowClassName(IntPtr hwnd) + { + var buffer = new char[256]; + var length = GetClassName(hwnd, buffer, buffer.Length); + return length <= 0 ? string.Empty : new string(buffer, 0, length); + } + + // --- Win32 interop (Windows-only; every caller is guarded) --- + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool EnumWindows(EnumWindowsProc proc, IntPtr lParam); + + private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindowVisible(IntPtr hWnd); + + /// Visibility read-back for the Win32 verification predicates. + internal static bool IsWindowVisibleWin32(IntPtr hwnd) => IsWindowVisible(hwnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsIconic(IntPtr hWnd); + + /// Minimized-state read-back for the Win32 show verification. + internal static bool IsIconicWin32(IntPtr hwnd) => IsIconic(hwnd); + + [StructLayout(LayoutKind.Sequential)] + private struct WindowRect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetWindowRect(IntPtr hWnd, out WindowRect rect); + + /// + /// The REAL on-screen bounds of a window (GetWindowRect) - the ground truth for the + /// post-launch verification: Chrome can silently clamp or reposition a launched window + /// (e.g. enforce a minimum size), and without this read-back that has zero visibility. + /// Null when the read fails (window gone). + /// + internal static (int Left, int Top, int Width, int Height)? GetWindowBoundsWin32(IntPtr hwnd) + { + if (!GetWindowRect(hwnd, out var rect)) + return null; + var width = rect.Right - rect.Left; + var height = rect.Bottom - rect.Top; + if (width <= 0 || height <= 0) + return null; + return (rect.Left, rect.Top, width, height); + } + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(IntPtr hWnd, [Out] char[] className, int maxCount); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); + + private const uint ProcessQueryLimitedInformation = 0x1000; + private const uint ProcessVmRead = 0x0010; + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint access, bool inheritHandle, uint processId); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ReadProcessMemory(IntPtr process, IntPtr baseAddress, [Out] byte[] buffer, IntPtr size, out IntPtr read); + + [DllImport("ntdll.dll")] + private static extern int NtQueryInformationProcess( + IntPtr process, int informationClass, ref ProcessBasicInformation info, int length, out int returnLength); + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessBasicInformation + { + public IntPtr Reserved1; + public IntPtr PebBaseAddress; + public IntPtr Reserved2_0; + public IntPtr Reserved2_1; + public IntPtr UniqueProcessId; + public IntPtr InheritedFromUniqueProcessId; + } + + /// The parent process id of , or 0 when unavailable. + private static int GetParentProcessId(int processId) + { + try + { + var handle = OpenProcess(ProcessQueryLimitedInformation, false, (uint)processId); + if (handle == IntPtr.Zero) + return 0; + try + { + var info = new ProcessBasicInformation(); + if (NtQueryInformationProcess(handle, 0, ref info, Marshal.SizeOf(), out _) != 0) + return 0; + return (int)info.InheritedFromUniqueProcessId; + } + finally + { + CloseHandle(handle); + } + } + catch + { + return 0; + } + } + + /// + /// The command line of a process read through its PEB (x64 only — the layout differs + /// under WoW64 and this fallback does not need it); null when access is denied. + /// + private static string? TryReadCommandLine(int processId) + { + if (!Environment.Is64BitProcess || !Environment.Is64BitOperatingSystem) + return null; + + try + { + var handle = OpenProcess(ProcessQueryLimitedInformation | ProcessVmRead, false, (uint)processId); + if (handle == IntPtr.Zero) + return null; + try + { + var info = new ProcessBasicInformation(); + if (NtQueryInformationProcess(handle, 0, ref info, Marshal.SizeOf(), out _) != 0) + return null; + + // PEB.ProcessParameters sits at 0x20 on x64; RTL_USER_PROCESS_PARAMETERS.CommandLine at 0x70. + var parameters = ReadInt64(handle, (long)info.PebBaseAddress + 0x20); + if (parameters == 0) + return null; + + var commandLine = ReadBytes(handle, parameters + 0x70, 16); + var length = BitConverter.ToUInt16(commandLine, 0); + var buffer = BitConverter.ToInt64(commandLine, 8); + if (length <= 0 || buffer == 0) + return null; + + var raw = ReadBytes(handle, buffer, length); + return System.Text.Encoding.Unicode.GetString(raw, 0, raw.Length); + } + finally + { + CloseHandle(handle); + } + } + catch + { + return null; + } + } + + private static long ReadInt64(IntPtr process, long address) + { + var raw = ReadBytes(process, address, 8); + return BitConverter.ToInt64(raw, 0); + } + + private static byte[] ReadBytes(IntPtr process, long address, int count) + { + var buffer = new byte[count]; + if (!ReadProcessMemory(process, (IntPtr)address, buffer, (IntPtr)count, out var read) || read.ToInt64() != count) + throw new InvalidOperationException($"ReadProcessMemory failed at 0x{address:X}."); + return buffer; + } +} diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/IChatPage.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/IChatPage.cs index d38cfc6..8943bc8 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/IChatPage.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/IChatPage.cs @@ -28,6 +28,25 @@ public bool IsDifferentFrom(AssistantStamp? other) } } +/// +/// The composer state snapshot taken before every send and after failures: text length, +/// attachment-chip counts and the send-control state. +/// +/// Characters currently in the input box. +/// How many attachment chips are visible. +/// How many chips show the failed/error state. +/// The send-control state (null when undetectable). +internal sealed record ComposerState(int TextLength, int ChipCount, int FailedChipCount, bool? SendEnabled); + +/// +/// The outcome of a composer reset. means the next message can be typed +/// safely; otherwise the caller fails the turn with KA_COMPOSER_NOT_CLEAN. +/// +/// The text is empty and no chips remain. +/// How the state was reached: "already clean", "text cleared", "chips removed", "page reloaded", "not clean". +/// How many chips were removed by clicking their remove controls. +internal sealed record ComposerResetResult(bool Clean, string Method, int ChipsRemoved); + /// /// The page operations the chat session needs — the seam that makes the reply-waiting logic /// unit-testable against a fake page. All operations are async and accept a CancellationToken @@ -128,6 +147,16 @@ internal interface IChatPage /// Task IsChatLimitNoticeVisibleAsync(CancellationToken cancellationToken = default); + /// + /// Fingerprint (item key + text hash) of the newest MESSAGE ITEM — user or assistant — + /// used by the send confirmation to detect that the sent message landed. Null when the + /// list is empty. + /// + Task GetNewestItemStampAsync(CancellationToken cancellationToken = default); + + /// The visible text of the newest message item (user or assistant), or null. + Task GetNewestItemTextAsync(CancellationToken cancellationToken = default); + /// /// Instant presence+visibility check across the fallback list in ONE round trip (no /// waiting) — for "is it there right now" questions: polling loops, login probes, notices. @@ -143,4 +172,34 @@ internal interface IChatPage /// Brings the tab to the front so the user can act on it (best-effort). Task FocusTabAsync(CancellationToken cancellationToken = default); + + /// Snapshots the composer: text length, chip/failed-chip counts, send state. + Task InspectComposerAsync(CancellationToken cancellationToken = default); + + /// + /// Makes the composer safe for the next message: waits out a still-running generation (up + /// to ), clears the text (select all + delete, + /// verified), removes every attachment chip (failed ones first, remove-control clicks + /// with re-inspection, at most 10 rounds in a 3 s budget), falls back to reloading the + /// chat page (which discards unsent drafts and attachments), and verifies the result. + /// Never throws for page-level failures — an unusable composer is reported through + /// . + /// + Task ResetComposerAsync(int replyTimeoutSeconds, CancellationToken cancellationToken = default); + + /// + /// Whether the page is online: navigator.onLine AND a cheap no-store probe of the chat + /// origin (any HTTP response counts as online). Cached for ~2 seconds. + /// + Task IsOnlineAsync(CancellationToken cancellationToken = default); + + /// Whether any attachment chip shows the failed/error state (the upload did not land). + Task AreFailedChipsVisibleAsync(CancellationToken cancellationToken = default); + + /// + /// Reloads the current chat page and waits for the input box to come back (the combined + /// wait helper). A reload discards unsent drafts and attachment chips — the safety net + /// when the chip selectors cannot be trusted. + /// + Task ReloadAndWaitForComposerAsync(CancellationToken cancellationToken = default); } diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/IChatPageInspector.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/IChatPageInspector.cs index 544516b..c6ac9df 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/IChatPageInspector.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/IChatPageInspector.cs @@ -33,4 +33,11 @@ internal interface IChatPageInspector /// (scripts, styles, svg removed, personal text masked), or null when nothing matched. /// Task CaptureTrimmedAsync(string[] selectors, CancellationToken cancellationToken = default); + + /// + /// Dumps every button-like element (tag, role, label, title, text, classes) on the newest + /// assistant message as a JSON string, so the doctor can show the real candidates when a + /// selector matches nothing. Returns "Not found" when no assistant message is visible. + /// + Task DumpButtonsAsync(string[] assistantMessageSelectors, CancellationToken cancellationToken = default); } diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatInspector.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatInspector.cs index c05b3de..472a0b4 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatInspector.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatInspector.cs @@ -80,6 +80,52 @@ public async Task IsInputEditableAsync(string[] selectors, CancellationTok return !readOnly && !disabled; } + /// + public async Task DumpButtonsAsync(string[] assistantMessageSelectors, CancellationToken cancellationToken = default) + { + for (var index = 0; index < assistantMessageSelectors.Length; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var count = (int)await WithCancellationAsync(_page.Locator(assistantMessageSelectors[index]).CountAsync(), cancellationToken); + if (count == 0) + continue; + + var newest = _page.Locator(assistantMessageSelectors[index]).Nth(count - 1); + var dump = await WithCancellationAsync(newest.EvaluateAsync(DumpButtonsScript), cancellationToken); + return dump?.ToString() ?? "Not found"; + } + catch (OperationCanceledException) + { + throw; + } + catch + { + // A broken selector is a finding, not a crash: try the next fallback. + } + } + + return "Not found"; + } + + /// + /// Collects tag/role/label/title/text/classes for every button-like element under the + /// message element, keeping only entries with at least one identifying attribute. + /// + private const string DumpButtonsScript = """ + element => JSON.stringify(Array.from(element.querySelectorAll('button, [role="button"]')).map(function(b) { + return { + tag: b.tagName, + role: b.getAttribute('role'), + label: b.getAttribute('aria-label'), + title: b.getAttribute('title'), + text: (b.textContent || '').trim().slice(0, 40), + classes: String(b.className).slice(0, 120) + }; + }).filter(function(x) { return x.label || x.title || x.text; })) + """; + /// public async Task CaptureTrimmedAsync(string[] selectors, CancellationToken cancellationToken = default) { diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatPage.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatPage.cs index be7cb4e..977b920 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatPage.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatPage.cs @@ -79,6 +79,59 @@ public async Task GetMessageItemCountAsync(CancellationToken cancellationTo return 0; } + private async Task GetNewItemLocatorAsync(CancellationToken cancellationToken) + { + foreach (var selector in _selectors.MessageItems) + { + try + { + var count = (int)await WithCancellationAsync(_page.Locator(selector).CountAsync(), cancellationToken); + if (count > 0) + return _page.Locator(selector).Nth(count - 1); + } + catch (OperationCanceledException) + { + throw; + } + catch + { + // The virtualized list re-renders constantly; try the next fallback selector. + } + } + + return null; + } + + /// + public async Task GetNewestItemStampAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var item = await GetNewItemLocatorAsync(cancellationToken); + if (item is null) + return null; + + var key = await EvaluateAsync(item, + "element => element.closest('[data-virtual-list-item-key]')?.getAttribute('data-virtual-list-item-key') ?? element.getAttribute('data-virtual-list-item-key')", cancellationToken); + var text = await EvaluateAsync(item, "element => element.innerText", cancellationToken); + var count = await GetMessageItemCountAsync(cancellationToken); + + return new AssistantStamp( + Key: string.IsNullOrWhiteSpace(key) ? null : key.Trim(), + TextHash: text is null ? 0 : StableHash(text), + VisibleCount: count); + } + + /// + public async Task GetNewestItemTextAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var item = await GetNewItemLocatorAsync(cancellationToken); + if (item is null) + return null; + + return await EvaluateAsync(item, "element => element.innerText", cancellationToken); + } + /// public async Task IsStopIndicatorVisibleAsync(CancellationToken cancellationToken = default) { @@ -112,24 +165,51 @@ public async Task IsInputClearedAsync(CancellationToken cancellationToken public async Task IsSendEnabledAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - foreach (var selector in _selectors.SendButton) + var watch = System.Diagnostics.Stopwatch.StartNew(); + var slowestIndex = -1; + double slowestMs = 0; + for (var index = 0; index < _selectors.SendButton.Length; index++) { + var selector = _selectors.SendButton[index]; try { var locator = _page.Locator(selector).First; - // Instant probe: absent elements cost one Evaluate round trip, never a wait. - // The disable state crosses as a JSON string so no bool/string mangling can - // flip the result (a raw bool would ToString as "True" and compare unequal). - // The disable-value assert is manual via `keyless doctor` (the unit tests run - // against FakeChatPage, which returns the bool directly). - var disabledJson = await EvaluateAsync(locator, - "element => JSON.stringify({ d: element.getAttribute('aria-disabled') === 'true' || String(element.className).includes('ds-button--disabled') })", + // A selector that matches nothing must cost ONE instant round trip, never a + // resolution wait: CountAsync never waits, EvaluateAsync would hold the probe + // open until its timeout when the element does not exist. + var count = await WithCancellationAsync(locator.CountAsync(), cancellationToken); + if (count == 0) + continue; // selector matched nothing; try the next fallback. + + var probe = System.Diagnostics.Stopwatch.StartNew(); + + // ONE round trip evaluates BOTH states: the disable state and the paperclip + // state (the iconLabelPrimary button next to the file input is the file-attach + // button, not send — a match there must be skipped, not reported). The states + // cross as a JSON string so no bool/string mangling can flip the result. + var stateJson = await EvaluateAsync(locator, + "element => JSON.stringify({ dis: element.getAttribute('aria-disabled') === 'true' || String(element.className).includes('ds-button--disabled'), paperclip: String(element.className).includes('ds-button--iconLabelPrimary') })", cancellationToken); - if (disabledJson is null) + if (probe.Elapsed.TotalMilliseconds > slowestMs) + { + slowestMs = probe.Elapsed.TotalMilliseconds; + slowestIndex = index; + } + if (stateJson is null) continue; // selector matched nothing; try the next fallback. - var isDisabled = IsDisabledFromJson(disabledJson); + var parsed = System.Text.Json.JsonDocument.Parse(stateJson); + var paperclip = parsed.RootElement.TryGetProperty("paperclip", out var paperclipValue) && + paperclipValue.ValueKind == System.Text.Json.JsonValueKind.True; + if (paperclip) + { + // The file-attach button is not the send control: keep looking. + continue; + } + + var isDisabled = parsed.RootElement.TryGetProperty("dis", out var disValue) && + disValue.ValueKind == System.Text.Json.JsonValueKind.True; return !isDisabled; } catch (OperationCanceledException) @@ -142,6 +222,13 @@ public async Task IsInputClearedAsync(CancellationToken cancellationToken } } + if (watch.Elapsed.TotalSeconds > 2) + { + _log.LogWarning( + "IsSendEnabledAsync was slow: {Seconds:N1}s total (slowest selector #{Index}: {Ms:N0} ms).", + watch.Elapsed.TotalSeconds, slowestIndex, slowestMs); + } + // No send-control selector matched: undetectable. Callers must not block on it — // Enter is the fallback anyway. return null; @@ -298,10 +385,233 @@ public async Task InsertTextAsync(string text, CancellationToken cancellationTok var (input, index) = match.Value; _log.LogDebug("InputBox matched selector #{Index}: {Selector}", index, _selectors.InputBox[index]); - await input.FillAsync(string.Empty); + await input.FillAsync(string.Empty, new LocatorFillOptions { Timeout = 5_000 }); await _page.Keyboard.InsertTextAsync(text); } + /// The composer-state probe script: text length of the input box. + private const string InputTextLengthScript = + "element => (element.value !== undefined ? String(element.value) : String(element.innerText || '')).length"; + + /// + /// The online probe: a cheap no-store HEAD of the chat origin with a 3 s abort timeout. + /// ANY HTTP response counts as online (the network is up even behind a captive portal). + /// + private const string OnlineProbeScript = """ + async () => { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + const response = await fetch(location.origin, { method: 'HEAD', cache: 'no-store', signal: controller.signal }); + clearTimeout(timer); + return true; + } catch { + return false; + } + } + """; + + private DateTime _onlineCheckedAt = DateTime.MinValue; + private bool _lastOnlineState = true; + + /// + public async Task InspectComposerAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var textLength = await GetInputTextLengthAsync(cancellationToken); + var chips = await CountVisibleAsync(_selectors.AttachmentChips, cancellationToken); + var failed = await CountVisibleAsync(_selectors.AttachmentFailed, cancellationToken); + var send = await IsSendEnabledAsync(cancellationToken); + return new ComposerState(textLength, chips, failed, send); + } + + /// + public async Task ResetComposerAsync(int replyTimeoutSeconds, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var watch = System.Diagnostics.Stopwatch.StartNew(); + + // (1) Never touch the composer while a reply is still generating. The wait is capped + // at 10 seconds: a broken StopIndicator selector that always matches would otherwise + // make every reset wait the full reply timeout (up to five minutes by default). + var generatingDeadline = DateTime.UtcNow.AddSeconds(Math.Min(replyTimeoutSeconds, 10)); + while (DateTime.UtcNow < generatingDeadline && + await IsStopIndicatorVisibleAsync(cancellationToken)) + { + await WithCancellationAsync(Task.Delay(100, cancellationToken), cancellationToken); + } + + var state = await InspectComposerAsync(cancellationToken); + if (state.TextLength == 0 && state.ChipCount == 0 && state.FailedChipCount == 0) + return new ComposerResetResult(true, "already clean", 0); + + // (2) Clear the textarea (select all + delete), then verify. + var method = "text cleared"; + await ClearTextInputAsync(cancellationToken); + state = await InspectComposerAsync(cancellationToken); + + // (3) Remove every chip, failed ones first: click the remove control inside the chip, + // re-inspect after each click, at most 10 rounds inside a 3 s total budget. + var chipsRemoved = 0; + var rounds = 0; + var chipDeadline = DateTime.UtcNow.AddSeconds(3); + while ((state.ChipCount > 0 || state.FailedChipCount > 0) && rounds < 10 && DateTime.UtcNow < chipDeadline) + { + rounds++; + var clicked = state.FailedChipCount > 0 + ? await ClickFirstVisibleAsync(_selectors.AttachmentFailed, cancellationToken) || + await ClickFirstVisibleAsync(_selectors.AttachmentRemove, cancellationToken) + : await ClickFirstVisibleAsync(_selectors.AttachmentRemove, cancellationToken) || + await ClickFirstVisibleAsync(_selectors.AttachmentFailed, cancellationToken); + if (!clicked) + break; + + chipsRemoved++; + method = "chips removed"; + await WithCancellationAsync(Task.Delay(100, cancellationToken), cancellationToken); + state = await InspectComposerAsync(cancellationToken); + } + + if (state.TextLength > 0) + await ClearTextInputAsync(cancellationToken); + + // (4) Reload fallback: a fresh load discards unsent drafts and attachments. + state = await InspectComposerAsync(cancellationToken); + if (state.TextLength > 0 || state.ChipCount > 0 || state.FailedChipCount > 0) + { + _log.LogWarning("Chip removal did not clean the composer; reloading the chat page to discard the draft."); + method = "page reloaded"; + await WithCancellationAsync(_page.ReloadAsync(new PageReloadOptions { Timeout = 10_000 }), cancellationToken); + await WaitForAnyVisibleAsync(_selectors.InputBox, 5_000, cancellationToken); + state = await InspectComposerAsync(cancellationToken); + } + + // (5) Verify: text empty AND no chips remain. + var clean = state.TextLength == 0 && state.ChipCount == 0 && state.FailedChipCount == 0; + if (watch.Elapsed.TotalSeconds > 2) + { + _log.LogWarning( + "ResetComposerAsync was slow: {Seconds:N1}s (result: {Result}, chips removed: {Chips}).", + watch.Elapsed.TotalSeconds, clean ? method : "not clean", chipsRemoved); + } + return new ComposerResetResult(clean, clean ? method : "not clean", chipsRemoved); + } + + /// + public async Task IsOnlineAsync(CancellationToken cancellationToken = default) + { + if ((DateTime.UtcNow - _onlineCheckedAt).TotalSeconds < 2) + return _lastOnlineState; + + _onlineCheckedAt = DateTime.UtcNow; + try + { + var onLine = await WithCancellationAsync(_page.EvaluateAsync("() => navigator.onLine === true"), cancellationToken); + _lastOnlineState = onLine && await WithCancellationAsync(_page.EvaluateAsync(OnlineProbeScript), cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _log.LogDebug("Online probe failed ({Message}); treating the page as offline.", ex.Message); + _lastOnlineState = false; + } + + return _lastOnlineState; + } + + /// + public async Task AreFailedChipsVisibleAsync(CancellationToken cancellationToken = default) + => await IsAnyVisibleAsync(_page, _selectors.AttachmentFailed, cancellationToken); + + /// + public async Task ReloadAndWaitForComposerAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + await WithCancellationAsync(_page.ReloadAsync(new PageReloadOptions { Timeout = 10_000 }), cancellationToken); + await WaitForAnyVisibleAsync(_selectors.InputBox, 5_000, cancellationToken); + } + + /// Counts visible elements across the fallback list (one combined locator). + private async Task GetInputTextLengthAsync(CancellationToken cancellationToken) + { + var match = await FirstVisibleAsync(_page, _selectors.InputBox, 10_000, cancellationToken); + if (match is null) + return 0; + + var raw = await EvaluateAsync(match.Value.Item1, InputTextLengthScript, cancellationToken); + return int.TryParse(raw, out var length) ? length : 0; + } + + /// Counts visible elements across the fallback list (one combined locator). + private async Task CountVisibleAsync(string[] selectors, CancellationToken cancellationToken) + { + if (selectors.Length == 0) + return 0; + + var combined = _page.Locator(selectors[0]); + for (var index = 1; index < selectors.Length; index++) + combined = combined.Or(_page.Locator(selectors[index])); + + try + { + return await WithCancellationAsync(combined.CountAsync(), cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return 0; + } + } + + /// Select-all + delete in the input box, then one fallback sweep; verified by the caller. + private async Task ClearTextInputAsync(CancellationToken cancellationToken) + { + var match = await FirstVisibleAsync(_page, _selectors.InputBox, 10_000, cancellationToken); + if (match is null) + return; + + var (input, _) = match.Value; + await WithCancellationAsync(input.ClickAsync(new LocatorClickOptions { Timeout = 3_000 }), cancellationToken); + await _page.Keyboard.PressAsync("Control+a"); + await _page.Keyboard.PressAsync("Delete"); + + var raw = await EvaluateAsync(input, InputTextLengthScript, cancellationToken); + if (int.TryParse(raw, out var length) && length > 0) + { + await _page.Keyboard.PressAsync("Control+a"); + await _page.Keyboard.PressAsync("Delete"); + } + } + + /// Clicks the first visible match of the fallback list; false when nothing matched. + private async Task ClickFirstVisibleAsync(string[] selectors, CancellationToken cancellationToken) + { + var match = await FirstVisibleAsync(_page, selectors, 1_000, cancellationToken); + if (match is null) + return false; + + try + { + await WithCancellationAsync(match.Value.Item1.ClickAsync(new LocatorClickOptions { Timeout = 3_000 }), cancellationToken); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _log.LogDebug("Remove-control click failed ({Message}); trying the next fallback.", ex.Message); + return false; + } + } + /// public async Task TryClickSendAsync(CancellationToken cancellationToken = default) { @@ -322,13 +632,17 @@ public async Task TryClickSendAsync(CancellationToken cancellationToken = try { var state = await EvaluateAsync(locator, - "element => JSON.stringify({ dis: element.getAttribute('aria-disabled') === 'true' || String(element.className).includes('ds-button--disabled'), paperclip: String(element.className).includes('ds-button--iconLabelPrimary') })", + "element => JSON.stringify({ dis: element.getAttribute('aria-disabled') === 'true' || String(element.className).includes('ds-button--disabled'), paperclip: String(element.className).includes('ds-button--iconLabelPrimary'), label: element.getAttribute('aria-label') || element.textContent || '', tag: element.tagName })", cancellationToken); + var clickedLabel = "unknown"; + var clickedTag = "unknown"; if (!string.IsNullOrEmpty(state)) { var parsed = System.Text.Json.JsonDocument.Parse(state); var dis = parsed.RootElement.TryGetProperty("dis", out var disValue) && disValue.ValueKind == System.Text.Json.JsonValueKind.True; var paperclip = parsed.RootElement.TryGetProperty("paperclip", out var paperclipValue) && paperclipValue.ValueKind == System.Text.Json.JsonValueKind.True; + clickedLabel = parsed.RootElement.TryGetProperty("label", out var labelValue) ? labelValue.GetString() ?? string.Empty : string.Empty; + clickedTag = parsed.RootElement.TryGetProperty("tag", out var tagValue) ? tagValue.GetString() ?? string.Empty : string.Empty; // Disabled buttons can never send... if (dis) @@ -346,8 +660,9 @@ public async Task TryClickSendAsync(CancellationToken cancellationToken = } } - _log.LogDebug("SendButton matched selector #{Index}: {Selector}", index, _selectors.SendButton[index]); - await locator.ClickAsync(); + _log.LogDebug("SendButton matched selector #{Index}: {Selector} (control <{Tag}>, label '{Label}')", + index, _selectors.SendButton[index], clickedTag, clickedLabel); + await locator.ClickAsync(new LocatorClickOptions { Timeout = 5_000 }); return true; } catch (OperationCanceledException) @@ -381,7 +696,10 @@ public async Task TryUploadFilesAsync(IReadOnlyList filePaths, Can try { var locator = _page.Locator(selector).First; - await locator.SetInputFilesAsync(filePaths.Select(p => p.Replace('\\', '/')).ToArray()); + // An absent input must cost 5 s, never the 30 s library default — the upload + // loop tries every fallback selector in turn. + await locator.SetInputFilesAsync(filePaths.Select(p => p.Replace('\\', '/')).ToArray(), + new LocatorSetInputFilesOptions { Timeout = 5_000 }); cancellationToken.ThrowIfCancellationRequested(); _log.LogDebug("FileInput matched selector: {Selector}", selector); return true; @@ -419,11 +737,16 @@ public async Task WaitForMessageCopyButtonAsync(TimeSpan timeout, Cancella cancellationToken.ThrowIfCancellationRequested(); var newest = await GetNewestAssistantLocatorAsync(cancellationToken); if (newest is null) + { + _log.LogWarning( + "MessageCopyButton: no assistant message is visible; {Count} selector(s) not tried ({Selectors}). Run 'keyless doctor --capture' and inspect newest-message.html to find the real aria-label.", + _selectors.MessageCopyButton.Length, string.Join(" | ", _selectors.MessageCopyButton)); return false; + } try { - await newest.HoverAsync(); + await newest.HoverAsync(new LocatorHoverOptions { Timeout = 2_000 }); } catch (OperationCanceledException) { @@ -438,7 +761,14 @@ public async Task WaitForMessageCopyButtonAsync(TimeSpan timeout, Cancella // timeout is the caller's upper bound once, never once per fallback selector. var match = await FirstVisibleAsync(newest, _selectors.MessageCopyButton, (int)timeout.TotalMilliseconds, cancellationToken); if (match is null) + { + // The reply source degrades to Dom here, which loses the raw Markdown; make the + // failure diagnosable instead of silent. + _log.LogWarning( + "MessageCopyButton did not match: tried {Count} selector(s) ({Selectors}) within {Timeout:F0}s. Run 'keyless selectors refresh' to pick up the latest copy-button selectors, or 'keyless doctor --capture' and inspect newest-message.html to find the real aria-label.", + _selectors.MessageCopyButton.Length, string.Join(" | ", _selectors.MessageCopyButton), timeout.TotalSeconds); return false; + } _log.LogDebug("MessageCopyButton matched selector #{Index}: {Selector}", match.Value.Index, _selectors.MessageCopyButton[match.Value.Index]); return true; @@ -455,7 +785,7 @@ private async Task ClickNewestCopyControlAsync(ILocator newest, Cancellati var totalMs = 3_000 * _selectors.MessageCopyButton.Length; var match = await FirstVisibleAsync(newest, _selectors.MessageCopyButton, totalMs, cancellationToken); if (match is null) - return false; + return await ClickCopyControlViaAccessibilityRoleAsync(newest, cancellationToken); _log.LogDebug("MessageCopyButton matched selector #{Index}: {Selector}", match.Value.Index, _selectors.MessageCopyButton[match.Value.Index]); try @@ -474,6 +804,80 @@ private async Task ClickNewestCopyControlAsync(ILocator newest, Cancellati } } + /// + /// Accessibility fallback: the live aria-label may be "Copy", "copy" or "Copy message", + /// which fixed strings miss. Resolves the copy control by accessible role + name regex, + /// first directly and then after hovering the newest message (the bar may be hover-only). + /// + private async Task ClickCopyControlViaAccessibilityRoleAsync(ILocator newest, CancellationToken cancellationToken) + { + const string successDirect = "Copy control matched via accessibility role"; + const string successHover = "Copy control matched after hover"; + + for (var attempt = 0; attempt < 2; attempt++) + { + try + { + if (attempt == 1) + { + await WithCancellationAsync(newest.HoverAsync(new LocatorHoverOptions { Timeout = 2_000 }), cancellationToken); + await Task.Delay(300, cancellationToken); + } + + var roleMatch = newest.GetByRole(AriaRole.Button, new LocatorGetByRoleOptions + { + NameRegex = new System.Text.RegularExpressions.Regex("copy", System.Text.RegularExpressions.RegexOptions.IgnoreCase), + }); + var count = await WithCancellationAsync(roleMatch.CountAsync(), cancellationToken); + if (count > 0) + { + await WithCancellationAsync( + roleMatch.First.ClickAsync(new LocatorClickOptions { Timeout = 3_000 }), cancellationToken); + _log.LogInformation(attempt == 0 ? successDirect : successHover); + return true; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _log.LogDebug("Accessibility copy fallback #{Attempt} failed: {Message}", attempt + 1, ex.Message); + } + } + + // Third strategy: enumerate every button-like element on the newest message and click + // the first whose label (aria-label / title / text) contains "copy" in any case. + try + { + var candidates = newest.Locator("button, [role='button']"); + var count = await WithCancellationAsync(candidates.CountAsync(), cancellationToken); + for (var i = 0; i < count; i++) + { + var label = await EvaluateAsync(candidates.Nth(i), + "element => [element.getAttribute('aria-label'), element.getAttribute('title'), (element.textContent || '').trim()].filter(Boolean).join(' | ').toLowerCase()", + cancellationToken); + if (string.IsNullOrEmpty(label) || !label.Contains("copy", StringComparison.OrdinalIgnoreCase)) + continue; + + await WithCancellationAsync(candidates.Nth(i).ClickAsync(new LocatorClickOptions { Timeout = 3_000 }), cancellationToken); + _log.LogInformation("Copy control matched via label scan (candidate #{Index}): {Label}", i, label); + return true; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _log.LogDebug("Label-scan copy fallback failed: {Message}", ex.Message); + } + + return false; + } + /// public async Task PrepareReplyCaptureAsync(CancellationToken cancellationToken = default) { @@ -696,7 +1100,12 @@ private async Task CountAssistantMessagesAsync(CancellationToken cancellati { try { - var result = await WithCancellationAsync(locator.EvaluateAsync(script), cancellationToken); + // HARD 1.5 s cap: when the locator matches nothing, Playwright resolves the + // element before evaluating and would otherwise hold every probe on the 30 s + // library default. Callers treat null as "matched nothing". + var result = await WithCancellationAsync( + locator.EvaluateAsync(script, new LocatorEvaluateOptions { Timeout = 1_500 }), + cancellationToken); return result?.ToString(); } catch (OperationCanceledException) @@ -818,22 +1227,6 @@ internal static async Task IsAnyVisibleAsync(IPage page, string[] selector /// Null, empty or invalid JSON reads as NOT disabled (the send probe then behaves as /// before: enabled unless proven otherwise). /// - internal static bool IsDisabledFromJson(string? disabledJson) - { - if (string.IsNullOrWhiteSpace(disabledJson)) - return false; - - try - { - using var document = System.Text.Json.JsonDocument.Parse(disabledJson); - return document.RootElement.TryGetProperty("d", out var d) && - d.ValueKind == System.Text.Json.JsonValueKind.True; - } - catch - { - return false; - } - } /// Non-generic task variant of the cancellation race. private static async Task WithCancellationAsync(Task task, CancellationToken cancellationToken) diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatProvider.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatProvider.cs index 0a155cc..da507c5 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatProvider.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatProvider.cs @@ -1,6 +1,8 @@ using ImanSoftware.KeylessAgent.Abstractions.Models; using ImanSoftware.KeylessAgent.Abstractions.Models.Agent; +using ImanSoftware.Extensions; using ImanSoftware.KeylessAgent.Abstractions.Models.Chat; +using ImanSoftware.KeylessAgent.Abstractions.Models.Enums; using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration; using ImanSoftware.KeylessAgent.Abstractions.Models.Snapshot; using ImanSoftware.KeylessAgent.Abstractions.Services; @@ -22,7 +24,7 @@ internal sealed class PlaywrightChatProvider : IChatProvider { private const int LoginWaitSeconds = 300; - private readonly IBrowserConnector _connector; + private readonly BrowserConnector _connector; private readonly IDateTimeProvider _time; private readonly ILoggerFactory _loggerFactory; private readonly IReplyExtractor _replyExtractor; @@ -45,26 +47,44 @@ public async Task> StartSessionAsync(ChatSessionOptions op { try { + // The launch command line travels into the turn log as a "chrome-launched" event, + // so every turn can prove which anti-occlusion flags Chrome received. + _connector.LogEventSink = options.LogEvent; + var connected = await _connector.ConnectAsync(options.Browser, new ProgressOnlyUi(), cancellationToken, options.Timings); if (connected.IsFailure) return Outcome.Failure(connected.Error); var (attachment, rawPage) = connected.Data; var page = (IPage)rawPage; + var log = new LoggerAdapter(_loggerFactory); + + // Hide the window IMMEDIATELY after the tab is open: Target.createTarget now opens + // the tab in the background, but Chrome can still move the window while the tab + // loads. The post-login hide below stays. Both Minimized and Offscreen mean + // offscreen; Normal is never forced. + if (options.Browser.MinimizeAfterLogin && + !string.Equals(options.Browser.WindowMode?.Trim(), "Normal", StringComparison.OrdinalIgnoreCase)) + { + await HideWindowAsync(page, options.Browser, attachment.LaunchedProcessId, log, cancellationToken); + } // Resume: when a saved chat URL exists and the current tab is not already on it, // navigate the tab there so the conversation continues in the same chat. if (!string.IsNullOrEmpty(options.ResumeChatUrl) && !page.Url.StartsWith(options.ResumeChatUrl, StringComparison.OrdinalIgnoreCase)) { + // Commit returns as soon as the navigation is acknowledged; the composer + // reset after login waits for the input box separately, so DOM-ready is not + // needed and a hung navigation cannot cost a full minute. await page.GotoAsync(options.ResumeChatUrl, new PageGotoOptions { - WaitUntil = WaitUntilState.DOMContentLoaded, - Timeout = 60_000, + WaitUntil = WaitUntilState.Commit, + Timeout = 15_000, }); } - var selectors = await SelectorsCatalog.LoadAsync(options.WorkspaceRoot, options.Browser.SelectorsFile, cancellationToken); + var selectors = await SelectorsCatalog.LoadAsync(options.WorkspaceRoot, options.Browser.SelectorsFile, cancellationToken, loggerFactory: _loggerFactory); if (selectors.IsFailure) return Outcome.Failure(selectors.Error); @@ -74,9 +94,29 @@ public async Task> StartSessionAsync(ChatSessionOptions op await WaitForLoginIfNeeded(chatPage, selectors.Data, options, cancellationToken); - // Logged in (or just logged in): get the window out of the user's way. + // One composer reset right after the session starts: stale text or attachment + // chips from an earlier failed turn must not leak into the first message. + try + { + var reset = await chatPage.ResetComposerAsync(options.Browser.ReplyTimeoutSeconds, cancellationToken); + if (!reset.Clean) + new LoggerAdapter(_loggerFactory).LogWarning( + "The composer is not clean after StartSession (method: {Method}); the next send resets it again.", reset.Method); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + new LoggerAdapter(_loggerFactory).LogWarning( + "The post-StartSession composer reset failed best-effort: {Message}", ex.Message); + } + + // Logged in (or just logged in): hide the window again — the login probe or the + // user's own interaction may have moved it back into view. if (options.Browser.MinimizeAfterLogin) - await MinimizeWindowAsync(page, cancellationToken); + await HideWindowAsync(page, options.Browser, attachment.LaunchedProcessId, log, cancellationToken); IChatSession session = new PlaywrightChatSession( chatPage, @@ -102,11 +142,18 @@ public async Task> StartSessionAsync(ChatSessionOptions op } } + /// + /// Test seam: replaces the login window controller (the login flow shows the hidden + /// window once so the user can log in — pure CDP window move). + /// + internal Func WindowControllerFactory { get; set; } = + options => new ChromeWindowController(); + /// /// Positive-first login detection: ONE parallel wait races the chat InputBox against the /// LoginIndicator. If the input appears first the user is logged in — no login wait ever /// runs, and absent elements never consume a per-fallback timeout. Only a winning login - /// indicator focuses the tab and waits for the manual login (LoginWaitSeconds). + /// indicator SHOWS the window and waits for the manual login (LoginWaitSeconds). /// internal async Task WaitForLoginIfNeeded( IChatPage page, SelectorsCatalog selectors, ChatSessionOptions options, @@ -130,7 +177,10 @@ internal async Task WaitForLoginIfNeeded( var logger = new LoggerAdapter(NullLoggerFactory.Instance); logger.LogWarning("Login page detected; waiting up to {Seconds}s for a manual login…", LoginWaitSeconds); - await page.FocusTabAsync(cancellationToken); + + // The login page would be INVISIBLE in a hidden window (minimized/offscreen): show it + // once, explicitly (pure CDP window move — no BringToFront focus steal of the terminal). + await WindowControllerFactory(options.Browser).ShowAsync(options.Browser, 0, cancellationToken); var deadline = DateTime.UtcNow.AddSeconds(LoginWaitSeconds); while (DateTime.UtcNow < deadline) @@ -156,38 +206,34 @@ internal async Task WaitForLoginIfNeeded( } /// - /// Minimizes the browser window via CDP (Browser.setWindowBounds) so a successful launch - /// does not steal the user's focus. Best-effort. + /// Hides the chat window OFFSCREEN so a successful launch does not steal the user's + /// focus: CDP Browser.setWindowBounds {left:-32000, top:-32000, width:1280, height:900, + /// windowState:normal} with a read-back verification and a normal→offscreen recovery, + /// then a Win32 fallback that finds the real Chrome top-level windows of our chrome.exe + /// processes and ShowWindow(SW_HIDE)s them (the launched launcher PID often has + /// MainWindowHandle == 0). Previously this used windowState:minimized, which throttles + /// Chrome's renderer and truncates DeepSeek streaming replies; the window is now MOVED + /// offscreen with normal state — the user cannot see it, Chrome does not throttle it. + /// Recover with 'keyless show' or the WPF Show Chrome button. Best-effort: every + /// failure is swallowed. /// - internal static async Task MinimizeWindowAsync(IPage page, CancellationToken cancellationToken = default) + internal static async Task HideWindowAsync( + IPage page, BrowserOptions browserOptions, int launchedProcessId, + ILoggerAdapter? log = null, CancellationToken cancellationToken = default) { - try - { - cancellationToken.ThrowIfCancellationRequested(); - var session = await page.Context.NewCDPSessionAsync(page); - var window = await session.SendAsync("Browser.getWindowForTarget"); - var windowId = ((System.Text.Json.JsonElement)window).GetProperty("windowId").GetInt64(); - await session.SendAsync("Browser.setWindowBounds", new Dictionary - { - ["windowId"] = windowId, - ["bounds"] = new Dictionary { ["windowState"] = "minimized" }, - }); - await session.DetachAsync(); - } - catch (OperationCanceledException) - { - throw; - } - catch - { - // Minimizing is best-effort. - } + await ChromeWindowMinimizer.HideAsync( + page, launchedProcessId, BrowserConnector.ResolveUserDataDir(browserOptions), + browserOptions.ChromeDebugPort, log, cancellationToken); } /// /// Dispose policy: the chat tab STAYS OPEN by default — the conversation continues across /// turns and across runs (the next run reuses the tab and the saved chat URL). Only /// Browser.CloseChromeOnExit closes the window/context when the session ends. + /// DISTINCTION from the WPF App.OnExit override: CloseChromeOnExit governs THIS per-turn + /// session disposal while the app runs (tab/context left open between turns); App.OnExit + /// is a separate, always-on safety net that closes the actual OS-level Chrome process the + /// app itself launched when the whole application exits. Keep them separate. /// internal static async ValueTask DisposeAsync(IPage page, BrowserOptions browserOptions, BrowserConnectionKind kind) { @@ -227,7 +273,23 @@ public void Say(string message) } public void ShowProgress(AgentProgressEvent progressEvent) - => Console.WriteLine($"[{progressEvent.Type}] {progressEvent.Detail}"); + => Console.WriteLine($"› {StepLabel(progressEvent.Type)}: {progressEvent.Detail}"); + + private static string StepLabel(AgentEventType type) => type switch + { + AgentEventType.SessionStarting => "setup", + AgentEventType.ConnectingToBrowser => "connect", + AgentEventType.OpeningChat => "chat", + AgentEventType.UploadingAttachments => "attachments", + AgentEventType.SendingMessage => "send", + AgentEventType.WaitingForReply => "reply", + AgentEventType.ValidatingReply => "reply", + AgentEventType.ExecutingCommand => "execute", + AgentEventType.SendingResults => "results", + AgentEventType.RefreshingSnapshot => "snapshot", + AgentEventType.TurnFinished => "done", + _ => type.ToString().ToLowerInvariant(), + }; public Task ConfirmAsync(ConfirmRequest request) => Task.FromResult(true); diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatSession.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatSession.cs index ce8b45c..aceb69f 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatSession.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatSession.cs @@ -38,6 +38,25 @@ internal sealed class PlaywrightChatSession : IChatSession /// Whether the DeepThink/Search toggles were already handled for this chat. private bool _togglesApplied; + /// 1-based counter of send attempts (names the attempt in logs and diagnostics). + private int _sendCounter; + + /// + /// Safety net for the untrusted chip selectors: set when a send with attachments ends + /// without a confirmed send, cleared only by a confirmed send. When set, the NEXT send + /// reloads the chat page before touching the composer. + /// + private bool _composerSuspect; + + /// + /// Copy-path circuit breaker: once the MessageCopyButton selectors missed within the + /// (shortened) wait, the copy attempt is skipped for the REST of this chat session — + /// every further wait would cost its whole budget and fall back to the DOM anyway. + /// A new session (a new run, or 'keyless doctor --capture') starts with the breaker open + /// again, which is how a fixed selector re-enables the copy path. + /// + private bool _copyButtonBroken; + public PlaywrightChatSession( IChatPage page, Func disposeAsync, @@ -65,15 +84,19 @@ public PlaywrightChatSession( public string LastReplyOrigin => LastReplyOriginValue switch { ReplyOrigin.Copy => "Copy", - ReplyOrigin.DomAfterRejectedCopy => "Dom (copy rejected)", - _ => "Dom", + ReplyOrigin.DomAfterRejectedCopy => "Dom: copy rejected by validation", + _ => LastReplyReason is null ? "Dom" : $"Dom: {LastReplyReason}", }; private ReplyOrigin LastReplyOriginValue { get; set; } = ReplyOrigin.Dom; + /// Why the reply came from the DOM instead of the Copy button, when it did. + private string? LastReplyReason { get; set; } + /// public async Task SendMessageAsync(string message, IReadOnlyList attachments, CancellationToken cancellationToken = default) { + var uploadedThisAttempt = new List(); try { await _rateLimiter.WaitAsync(cancellationToken); @@ -88,6 +111,42 @@ public async Task SendMessageAsync(string message, IReadOnlyList 0) + _log.LogWarning("Composer reset removed {Count} stale chip(s) (not from this attempt).", reset.ChipsRemoved); + + if (!await _page.IsOnlineAsync(cancellationToken) && !await WaitForNetworkAsync(cancellationToken)) + { + _log.LogWarning("Offline: nothing was uploaded or sent."); + if (attachments.Count > 0) + _composerSuspect = true; // chips/text may be on the page from an earlier attempt + return Outcome.Failure(OfflineError()); + } + // --- Step 1: fingerprint the current newest assistant message (pre-send baseline) --- _preSendStamp = await _page.GetNewestAssistantStampAsync(cancellationToken); var itemCountBefore = await _page.GetMessageItemCountAsync(cancellationToken); @@ -98,7 +157,7 @@ public async Task SendMessageAsync(string message, IReadOnlyList delivery; try { - delivery = await DeliverAttachmentsAsync(attachments, cancellationToken); + delivery = await DeliverAttachmentsAsync(attachments, uploadedThisAttempt, cancellationToken); } finally { @@ -106,7 +165,15 @@ public async Task SendMessageAsync(string message, IReadOnlyList 0) + _composerSuspect = true; return delivery; + } if (delivery.Data == AttachmentDelivery.FellBackToPaste) { _options.Timings?.Start("paste"); @@ -117,7 +184,20 @@ public async Task SendMessageAsync(string message, IReadOnlyList confirmed; - try + // Two attempts max: attempt 1 clicks the send control (Enter when the click is + // impossible), attempt 2 is the single Enter retry. Each attempt gets its own + // confirm window; between them the signals are re-checked so a slow message is + // never followed by a duplicate. + for (var attempt = 1; ; attempt++) { - confirmed = Outcome.Successful(await ConfirmSendAsync(itemCountBefore, cancellationToken)); - } - finally - { - _options.Timings?.Stop("send-confirm"); - } + _options.Timings?.Start("send"); + try + { + if (attempt == 1) + { + // Require an enabled send control when it can be detected (as for + // uploads): clicking a detectably-disabled control is a wasted attempt. + var enableDeadline = DateTime.UtcNow.AddSeconds(2); + while (DateTime.UtcNow < enableDeadline) + { + var state = await _page.IsSendEnabledAsync(cancellationToken); + if (state is null or true) + break; + await Task.Delay(100, cancellationToken); + } + } - if (!confirmed.Data) - { - // The list is virtualized: a constant item count proves nothing. An emptied input - // means the text left the composer — the message was sent. - if (await _page.IsInputClearedAsync(cancellationToken)) + if (attempt == 1) + { + // A hung click must not cost the default Playwright timeout: 10 s, + // then the Enter fallback takes over within the same attempt. + using var clickCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + clickCts.CancelAfter(TimeSpan.FromSeconds(10)); + var clicked = await _page.TryClickSendAsync(clickCts.Token); + if (!clicked) + await _page.PressEnterAsync(cancellationToken); + } + else + { + await _page.PressEnterAsync(cancellationToken); + } + } + finally { - _log.LogInformation("Send not confirmed by the message list, but the input was cleared; treating the message as sent."); - return Outcome.Successful(); + _options.Timings?.Stop("send"); } - // The text is still in the composer: exactly one retry, with ENTER. The button - // click was already proven ineffective (or it would have been confirmed), and - // re-clicking a mis-identified control (e.g. the paperclip) only opens dialogs. - _log.LogWarning("Send not confirmed within {Seconds}s and the input still holds the text; retrying once with Enter.", _options.Browser.SendConfirmSeconds); - await _page.PressEnterAsync(cancellationToken); - - _options.Timings?.Start("send-confirm"); - Outcome confirmedAfterRetry; + // --- Confirm the send actually happened --- + var attemptName = $"send-confirm#{attempt}"; + _options.Timings?.Start(attemptName); + bool confirmed; + SendSignals signals; + var confirmWatch = System.Diagnostics.Stopwatch.StartNew(); try { - confirmedAfterRetry = Outcome.Successful(await ConfirmSendAsync(itemCountBefore, cancellationToken)); + (confirmed, signals) = await ConfirmSendAsync(itemStampBefore, sentPrefix, cancellationToken); } finally { - _options.Timings?.Stop("send-confirm"); + _options.Timings?.Stop(attemptName); } - if (!confirmedAfterRetry.Data) - { - if (await _page.IsInputClearedAsync(cancellationToken)) + var itemCountAfter = await _page.GetMessageItemCountAsync(cancellationToken); + _log.LogWarning( + "Send attempt #{Attempt} {Outcome}: items {Before}->{After}, fingerprintChanged {Fingerprint}, userTextMatched {UserText}, inputCleared {Cleared}, chips {Chips}, stopVisible {Stop}, elapsed {Elapsed:N1}s.", + attempt, confirmed ? "confirmed" : "unconfirmed", itemCountBefore, itemCountAfter, + signals.FingerprintChanged, signals.UserTextMatched, signals.InputCleared, + signals.Chips, signals.StopVisible, confirmWatch.Elapsed.TotalSeconds); + if (_options.LogEvent is not null) + await _options.LogEvent("send-attempt", new { - _log.LogInformation("Send confirmed after retry by the cleared input."); - return Outcome.Successful(); - } + Attempt = attempt, + Confirmed = confirmed, + ItemCountBefore = itemCountBefore, + ItemCountAfter = itemCountAfter, + signals.FingerprintChanged, + signals.UserTextMatched, + signals.InputCleared, + signals.Chips, + signals.StopVisible, + }); + + // A dropped connection while the confirm window was open stops the wait at + // once: clean the composer and report the network, not a generic timeout. + if (!signals.Online) + { + _log.LogWarning("The connection dropped while confirming the send; cleaning the composer."); + await CleanupComposerAsync(uploadedThisAttempt, cancellationToken); + if (attachments.Count > 0) + _composerSuspect = true; + return Outcome.Failure(OfflineError()); + } + + if (confirmed) + { + _composerSuspect = false; // a confirmed send proves the composer is clean again + return Outcome.Successful(); + } + + // Re-check BEFORE any retry: a slow message may have landed after the window + // closed — resending it would create a duplicate. + var late = await ObserveSendSignalsAsync(itemStampBefore, sentPrefix, cancellationToken); + if (Confirmed(late)) + { + _log.LogInformation("Send confirmed after the confirm window closed (signals arrived late); no retry."); + _composerSuspect = false; + return Outcome.Successful(); + } + // The text left the composer: the message was sent even though the confirm + // signals stayed silent (same rule as the confirm window, applied late). + if (await _page.IsInputClearedAsync(cancellationToken)) + { + _log.LogInformation("Send treated as delivered: the input no longer holds the text (attachment still attached if any)."); + _composerSuspect = false; + return Outcome.Successful(); + } + + if (attempt == 2) + { var screenshot = await CaptureDiagnosticsAsync("send-not-confirmed", cancellationToken); + if (uploadedThisAttempt.Count > 0) + await CleanupComposerAsync(uploadedThisAttempt, cancellationToken); + if (attachments.Count > 0) + _composerSuspect = true; return Outcome.Failure(new OutcomeError( $"The message was not sent within {2 * _options.Browser.SendConfirmSeconds}s (input still holds the text, no generating indicator)." + DiagnosticsHint(screenshot), ErrorCodes.SendNotConfirmed, OutcomeErrorType.Failure)); } - } - return Outcome.Successful(); + // The text is still in the composer: exactly one retry, with ENTER. The button + // click was already proven ineffective (or it would have been confirmed), and + // re-clicking a mis-identified control (e.g. the paperclip) only opens dialogs. + _log.LogWarning("Send not confirmed within {Seconds}s and the input still holds the text; retrying once with Enter.", _options.Browser.SendConfirmSeconds); + } } catch (Exception ex) { await CaptureDiagnosticsAsync("send-failed", cancellationToken); + if (uploadedThisAttempt.Count > 0) + { + await CleanupComposerAsync(uploadedThisAttempt, cancellationToken); + _composerSuspect = true; + } return Outcome.Failure(new OutcomeError(ex)); } } + /// + /// Waits (100 ms polling) while the stop/generating indicator is visible, up to + /// Browser.ReplyTimeoutSeconds — used before touching or reloading the composer. + /// + private async Task WaitForGenerationAsync(CancellationToken cancellationToken) + { + // Capped at 10 seconds: a StopIndicator selector that always matches would otherwise + // make every composer reload wait the full reply timeout. + var deadline = DateTime.UtcNow.AddSeconds(Math.Min(_options.Browser.ReplyTimeoutSeconds, 10)); + while (DateTime.UtcNow < deadline && await _page.IsStopIndicatorVisibleAsync(cancellationToken)) + await Task.Delay(100, cancellationToken); + } + + /// The failure raised whenever a send is attempted while the machine is offline. + private static OutcomeError OfflineError() => new( + "No internet connection. Nothing was sent. Check your connection and try again.", + ErrorCodes.NetworkOffline, OutcomeErrorType.Failure); + + /// + /// Waits (polling every 2 s) for the connection to come back when + /// Browser.WaitForNetworkSeconds is greater than zero; false when it stays offline + /// or the wait is disabled. + /// + private async Task WaitForNetworkAsync(CancellationToken cancellationToken) + { + var seconds = _options.Browser.WaitForNetworkSeconds; + if (seconds <= 0) + return false; + + _log.LogWarning("No internet connection. Waiting for the connection… (up to {Seconds}s)", seconds); + var deadline = DateTime.UtcNow.AddSeconds(seconds); + while (DateTime.UtcNow < deadline) + { + await Task.Delay(2000, cancellationToken); + if (await _page.IsOnlineAsync(cancellationToken)) + { + _log.LogInformation("The connection is back; continuing."); + return true; + } + } + + return false; + } + + /// + /// Best-effort composer cleanup after a failed/timed-out/cancelled attempt: removes stale + /// chips and text so the NEXT message can be sent. Never throws; logs what was removed, + /// by which method, and which files THIS attempt had uploaded (so the log can say the + /// chips were "not from this attempt"). + /// + private async Task CleanupComposerAsync(IReadOnlyList uploadedThisAttempt, CancellationToken cancellationToken) + { + try + { + var reset = await _page.ResetComposerAsync(_options.Browser.ReplyTimeoutSeconds, cancellationToken); + if (reset.ChipsRemoved > 0) + _log.LogWarning( + "Composer cleanup removed {Count} stale chip(s) (not from this attempt — this attempt uploaded: {Files}) via {Method}.", + reset.ChipsRemoved, + uploadedThisAttempt.Count == 0 ? "nothing" : string.Join(", ", uploadedThisAttempt), + reset.Method); + else if (!reset.Clean) + _log.LogWarning("Composer cleanup could not reach a clean state (method: {Method}).", reset.Method); + } + catch (Exception ex) + { + // The cleanup is best-effort: a closed page or a dying tab must never turn the + // original failure into a different exception. + _log.LogWarning("Composer cleanup failed best-effort: {Message}", ex.Message); + } + } + private bool _capturePrepared; @@ -209,6 +433,8 @@ await Task.Delay(_random.Next( /// Decides the reply source: Dom = rendered text; Copy = capture (hover + click newest /// message's Copy control, interception then OS clipboard with restore) validated against /// the DOM text; Auto = copy first, fall back to Dom on capture/validation failure. + /// The whole copy ATTEMPT is one timed "copy" phase and every skip/fallback decision is + /// logged (Warning + turn log) so a "via Dom" reply always carries its reason. /// private async Task> ReadReplyTextAsync(string domText, CancellationToken cancellationToken) { @@ -216,6 +442,9 @@ private async Task> ReadReplyTextAsync(string domText, if (mode == ReplySourceMode.Dom) { LastReplyOriginValue = ReplyOrigin.Dom; + LastReplyReason = "Browser.ReplySource=Dom"; + if (_options.LogEvent is not null) + await _options.LogEvent("reply-source", new { Source = "Dom", Reason = LastReplyReason }); return Outcome.Successful(new ReplyReadResult(domText, ReplyOrigin.Dom)); } @@ -226,31 +455,56 @@ private async Task> ReadReplyTextAsync(string domText, _capturePrepared = true; } - // The message-level Copy control may appear on hover only; wait within the reply timeout. - var buttonWait = await _page.WaitForMessageCopyButtonAsync( - TimeSpan.FromSeconds(Math.Min(_options.Browser.ReplyTimeoutSeconds, 10)), cancellationToken); - if (!buttonWait) + // Circuit breaker open: the copy selectors already missed once this session — every + // further wait would burn its whole budget and land on the DOM anyway. Straight to + // the DOM extraction, no waiting at all. + if (_copyButtonBroken) { - if (mode == ReplySourceMode.Copy) - { - return Outcome.Failure(new OutcomeError( - "The message Copy button did not appear within the reply timeout.", - ErrorCodes.ReplyTimeout, OutcomeErrorType.Failure)); - } - LastReplyOriginValue = ReplyOrigin.Dom; + LastReplyReason = "copy disabled after the first miss this session"; return Outcome.Successful(new ReplyReadResult(domText, ReplyOrigin.Dom)); } - var reasoning = await _page.GetNewestReasoningTextAsync(cancellationToken); _options.Timings?.Start("copy"); + string? validationSummary = null; Outcome read; try { + // The message-level Copy control may appear on hover only; the wait is capped at + // 3 s — the selector either matches almost immediately or it does not match at + // all, and a longer wait only delays the DOM fallback. + var buttonWait = await _page.WaitForMessageCopyButtonAsync( + TimeSpan.FromSeconds(Math.Min(_options.Browser.ReplyTimeoutSeconds, 3)), cancellationToken); + if (!buttonWait) + { + _copyButtonBroken = true; // one miss is enough: skip the copy path for the session + LastReplyOriginValue = ReplyOrigin.Dom; + LastReplyReason = "copy button not matched"; + _log.LogWarning( + "Copy attempt skipped: no MessageCopyButton fallback matched (reply text from the DOM)." + + " The copy path is disabled for the rest of this chat session;" + + " run 'keyless doctor --capture' to find the real selector."); + if (_options.LogEvent is not null) + await _options.LogEvent("reply-source", new { Source = "Dom", Reason = LastReplyReason }); + return Outcome.Successful(new ReplyReadResult(domText, ReplyOrigin.Dom)); + } + + if (mode == ReplySourceMode.Copy) + { + return Outcome.Failure(new OutcomeError( + "The message Copy button did not appear within the reply timeout.", + ErrorCodes.ReplyTimeout, OutcomeErrorType.Failure)); + } + + var reasoning = await _page.GetNewestReasoningTextAsync(cancellationToken); read = await ReplyReader.ReadAsync( mode, new ReplyReadContext(domText, reasoning, ct => _page.TryCaptureCopiedReplyAsync(TimeSpan.FromSeconds(5), ct)), - summary => _log.LogInformation("Reply copy validation: {Summary}", summary), + summary => + { + validationSummary = summary; + _log.LogInformation("Reply copy validation: {Summary}", summary); + }, cancellationToken); } finally @@ -259,9 +513,30 @@ private async Task> ReadReplyTextAsync(string domText, } if (read.IsFailure) + { + _log.LogWarning("Copy attempt failed: {Message}", read.Error.Message); + if (_options.LogEvent is not null) + await _options.LogEvent("reply-source", new { Source = "Copy (failed)", Reason = read.Error.Message }); return read; + } LastReplyOriginValue = read.Data.Origin; + if (read.Data.Origin == ReplyOrigin.Copy) + { + LastReplyReason = null; + if (_options.LogEvent is not null) + await _options.LogEvent("reply-source", new { Source = "Copy", Validation = validationSummary }); + } + else + { + // Auto fell back: the capture produced nothing usable or validation rejected it — + // the validation numbers (copied/dom/similarity/contained) explain which. + LastReplyReason = "copy rejected by validation"; + _log.LogWarning("Copy rejected by validation ({Summary}); reply text from the DOM.", validationSummary); + if (_options.LogEvent is not null) + await _options.LogEvent("reply-source", new { Source = "Dom (copy rejected)", Reason = LastReplyReason, Validation = validationSummary }); + } + return read; } @@ -324,16 +599,45 @@ public async Task> WaitForReplyAsync(CancellationToken cancellat var stableReply = (string?)null; var firstTokenSeen = false; + // Truncation bookkeeping for THIS reply wait only. The budget is WALL-CLOCK time + // since the FIRST truncation detection - not an attempt count - so a stream that + // resumes after a few real seconds survives, while a permanently stuck one fails + // once Browser.TruncationBudgetSeconds is spent. With AutoFocusOnTruncation the + // window is focused once, after a quarter of the budget has elapsed. + var truncationRetries = 0; + var focusTriggered = false; + var truncationStartedAt = (DateTime?)null; + if (await _page.IsChatLimitNoticeVisibleAsync(cancellationToken)) return ChatExhausted(); _options.Timings?.Start("first-token"); + var offlinePolls = 0; try { while (DateTime.UtcNow < deadline) { cancellationToken.ThrowIfCancellationRequested(); + // A dropped connection never brings the reply back by waiting longer: after + // two consecutive offline polls end the wait — the chat session stays valid + // and the same request can be resent. + if (!await _page.IsOnlineAsync(cancellationToken)) + { + offlinePolls++; + if (offlinePolls >= 2) + { + _log.LogWarning("The connection dropped while waiting for the reply; the chat session stays valid — resend the same request."); + return Outcome.Failure(new OutcomeError( + "No internet connection. The reply did not arrive. The chat session stays valid — resend the same request.", + ErrorCodes.NetworkOffline, OutcomeErrorType.Failure)); + } + } + else + { + offlinePolls = 0; + } + var stillGenerating = await _page.IsStopIndicatorVisibleAsync(cancellationToken); var stamp = await _page.GetNewestAssistantStampAsync(cancellationToken); var isNewMessage = stamp is not null && stamp.IsDifferentFrom(_preSendStamp); @@ -353,6 +657,64 @@ public async Task> WaitForReplyAsync(CancellationToken cancellat !string.IsNullOrWhiteSpace(reply) && reply == stableReply) { + // A minimized Chrome can throttle the DeepSeek stream mid-reply (the Stop + // indicator blinks off between chunks), so a "complete" AgentScript reply + // that lacks @@eof is TRUNCATED and must never be accepted: keep polling — + // the streaming session stays alive on DeepSeek's side and further tokens + // may still arrive. + var looksLikeAgentScript = reply.Contains("@@", StringComparison.Ordinal); + var endsWithEof = reply.TrimEnd().EndsWith("@@eof", StringComparison.OrdinalIgnoreCase); + if (looksLikeAgentScript && !endsWithEof) + { + // Wall-clock budget: time since the FIRST detection, not a count of + // detections - detections are spaced by the normal poll delay, so a + // stream that resumes after a few real seconds still completes. + var now = DateTime.UtcNow; + truncationStartedAt ??= now; + var elapsed = (now - truncationStartedAt.Value).TotalSeconds; + var budget = _options.Browser.TruncationBudgetSeconds; + truncationRetries++; + + var tail = reply.Length <= 60 ? reply : reply[^60..]; + _log.LogWarning("Truncated reply detected (attempt {N}): length={Len} last60=\"{Tail}\"", + truncationRetries, reply.Length, tail); + if (_options.LogEvent is not null) + await _options.LogEvent("reply-truncated", new + { + Length = reply.Length, + Tail = tail, + Attempt = truncationRetries, + FocusTriggered = focusTriggered, + ElapsedSeconds = Math.Round(elapsed, 1), + }); + + if (elapsed >= budget) + { + // The budget is spent: the Chrome flags did not prevent the + // throttling on this machine. Never return the truncated text. + return Outcome.Failure(new OutcomeError( + $"Reply stream was cut off repeatedly for {elapsed:F0}s in this turn. The chat session stays valid. Resend the same request. If this repeats, set Browser.AutoFocusOnTruncation to true in keyless.json, or use WindowMode:Offscreen.", + ErrorCodes.ReplyTruncated, OutcomeErrorType.Failure)); + } + + // Opt-in recovery only: most users want the window to STAY hidden + // (focusing steals the terminal from them), so the default path never + // focuses. When opted in, fire once after a quarter of the budget. + if (_options.Browser.AutoFocusOnTruncation && + elapsed >= budget * 0.25 && + !focusTriggered) + { + focusTriggered = true; // at most once per WaitForReplyAsync call + _log.LogWarning("Reply stream was throttled for {Elapsed:F1}s ({Attempts} detections); un-minimizing the window once as recovery (Browser.AutoFocusOnTruncation).", + elapsed, truncationRetries); + await _page.FocusTabAsync(cancellationToken); + } + + stableReply = null; // stability must re-accumulate before the next check + await Task.Delay(_pollDelayMs, cancellationToken); // space detections out like the normal poll, never busy-loop + continue; + } + // Completion detected. The DOM text is the baseline; the reply SOURCE (raw // Markdown from the Copy button vs DOM extraction) is decided per config. var readOutcome = await ReadReplyTextAsync(reply, cancellationToken); @@ -390,33 +752,78 @@ public async Task> WaitForReplyAsync(CancellationToken cancellat /// public async ValueTask DisposeAsync() => await _disposeAsync(); + /// The signals observed while a send confirmation window is open. + private sealed record SendSignals( + bool FingerprintChanged, + bool UserTextMatched, + bool InputCleared, + bool Chips, + bool StopVisible, + bool Online = true); + + private static bool Confirmed(SendSignals s) + => s.FingerprintChanged || s.UserTextMatched || (s.InputCleared && !s.Chips) || s.StopVisible; + + /// The first 40 characters of the sent text, whitespace-collapsed. + private static string SentPrefixOf(string text) + { + var normalized = string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + return normalized.Length <= 40 ? normalized : normalized[..40]; + } + + private static string NormalizeWhitespace(string text) + => string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + /// One snapshot of all confirm signals. + private async Task ObserveSendSignalsAsync( + AssistantStamp? itemStampBefore, string sentPrefix, CancellationToken cancellationToken) + { + var stamp = await _page.GetNewestItemStampAsync(cancellationToken); + var fingerprintChanged = stamp is not null && stamp.IsDifferentFrom(itemStampBefore); + + var itemText = await _page.GetNewestItemTextAsync(cancellationToken); + var userTextMatched = false; + if (itemText is not null && sentPrefix.Length > 0 && + NormalizeWhitespace(itemText).StartsWith(sentPrefix, StringComparison.Ordinal)) + userTextMatched = true; + + var inputCleared = await _page.IsInputClearedAsync(cancellationToken); + var chips = await _page.AreAttachmentChipsVisibleAsync(cancellationToken); + var stopVisible = await _page.IsStopIndicatorVisibleAsync(cancellationToken); + var online = await _page.IsOnlineAsync(cancellationToken); + + return new SendSignals(fingerprintChanged, userTextMatched, inputCleared, chips, stopVisible, online); + } + /// - /// A send is confirmed when a new message item appeared in the DOM, or the input was - /// consumed and the stop/generating indicator is on. + /// A send is confirmed when ANY of these hold: the newest message item's fingerprint + /// changed, a new user message starts with the sent text, the input is cleared with no + /// attachment chips remaining, or the stop/generating indicator is visible. Polled at + /// ~100 ms; the final observation after the deadline catches late flips. /// - private async Task ConfirmSendAsync(int itemCountBefore, CancellationToken cancellationToken) + private async Task<(bool Confirmed, SendSignals Signals)> ConfirmSendAsync( + AssistantStamp? itemStampBefore, string sentPrefix, CancellationToken cancellationToken) { var deadline = DateTime.UtcNow.AddSeconds(_options.Browser.SendConfirmSeconds); while (DateTime.UtcNow < deadline) { cancellationToken.ThrowIfCancellationRequested(); - if (await _page.GetMessageItemCountAsync(cancellationToken) > itemCountBefore) - return true; + var signals = await ObserveSendSignalsAsync(itemStampBefore, sentPrefix, cancellationToken); + if (!signals.Online) + return (false, signals); // the caller cleans up and reports the network + if (Confirmed(signals)) + return (true, signals); - if (await _page.IsInputClearedAsync(cancellationToken) && - await _page.IsStopIndicatorVisibleAsync(cancellationToken)) - { - return true; - } - - await Task.Delay(Math.Min(_pollDelayMs, 250), cancellationToken); + await Task.Delay(_pollDelayMs, cancellationToken); } - return false; + var final = await ObserveSendSignalsAsync(itemStampBefore, sentPrefix, cancellationToken); + return (Confirmed(final), final); } - private async Task> DeliverAttachmentsAsync(IReadOnlyList attachments, CancellationToken cancellationToken) + private async Task> DeliverAttachmentsAsync( + IReadOnlyList attachments, List uploadedThisAttempt, CancellationToken cancellationToken, bool retried = false) { if (attachments.Count == 0) return Outcome.Successful(AttachmentDelivery.NothingToAttach); @@ -437,6 +844,10 @@ private async Task> DeliverAttachmentsAsync(IReadOnl created.Add(path); } + // The names this attempt actually uploaded — the cleanup log distinguishes "stale + // chips (not from this attempt)" from this attempt's own files. + uploadedThisAttempt.AddRange(attachments.Select(a => a.Name)); + if (!await _page.TryUploadFilesAsync(created, cancellationToken)) { _log.LogWarning("SetInputFiles failed; pasting the attachment content instead."); @@ -447,6 +858,7 @@ private async Task> DeliverAttachmentsAsync(IReadOnl // finished. Wait for both: a positive upload sign (files registered / chips visible) // AND an enabled send control, as long as the send control can be detected at all // (when none of its selectors match, do not block on it — Enter is the fallback). + // A dropped connection or a chip in the failed state ends the wait at once. var uploadNames = attachments.Select(a => a.Name).ToList(); var deadline = DateTime.UtcNow.AddSeconds(_options.Browser.UploadWaitSeconds); var uploadSign = false; @@ -457,6 +869,24 @@ private async Task> DeliverAttachmentsAsync(IReadOnl { cancellationToken.ThrowIfCancellationRequested(); + if (!await _page.IsOnlineAsync(cancellationToken)) + { + _log.LogWarning("The connection dropped while the upload was pending; cleaning the composer."); + return Outcome.Failure(OfflineError()); + } + + // A chip in the failed/error state means the upload is DONE FAILING — no point + // waiting out the window. Clean the composer and paste only when online. + if (await _page.AreFailedChipsVisibleAsync(cancellationToken)) + { + _log.LogWarning("An attachment chip reports a failed upload; cleaning the composer."); + await CleanupComposerAsync(uploadedThisAttempt, cancellationToken); + if (!await _page.IsOnlineAsync(cancellationToken)) + return Outcome.Failure(OfflineError()); + + return Outcome.Successful(AttachmentDelivery.FellBackToPaste); + } + if (!uploadSign && await HasUploadSignAsync(uploadNames, cancellationToken)) { uploadSign = true; @@ -489,6 +919,29 @@ private async Task> DeliverAttachmentsAsync(IReadOnl } } + // Second dirty signal, independent of the chip selectors: the send control is + // DETECTED and stayed disabled for the whole window while InspectComposerAsync + // shows no chips at all and the page is online. That composer state cannot send — + // reload once, redo the whole delivery once, then give up with a precise code. + var composerNow = await _page.InspectComposerAsync(cancellationToken); + var suspicious = sendSeen && !sendEnabled + && composerNow.ChipCount == 0 && composerNow.FailedChipCount == 0 + && await _page.IsOnlineAsync(cancellationToken); + + if (suspicious && retried) + return Outcome.Failure(new OutcomeError( + "The composer did not recover after a reload: the send control stayed disabled with no attachment chips. Nothing was sent.", + ErrorCodes.ComposerNotClean, OutcomeErrorType.Failure)); + + if (suspicious) + { + _log.LogWarning( + "The send control stayed disabled for {Seconds}s with no visible chips; reloading the chat and retrying the delivery once.", + _options.Browser.UploadWaitSeconds); + await _page.ReloadAndWaitForComposerAsync(cancellationToken); + return await DeliverAttachmentsAsync(attachments, uploadedThisAttempt, cancellationToken, retried: true); + } + if (uploadSign && (!sendSeen || sendEnabled)) { if (!sendEnabled) diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/SelectorsCatalog.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/SelectorsCatalog.cs index d02026e..2a927bf 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/SelectorsCatalog.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/SelectorsCatalog.cs @@ -14,10 +14,20 @@ namespace ImanSoftware.KeylessAgent.Core.Implementation.Browser; /// so they can be fixed without recompiling. Falls back to the embedded default catalog /// and writes that default to the workspace when the file is missing. /// -internal sealed class SelectorsCatalog +public sealed class SelectorsCatalog { private const string EmbeddedResourceName = "ImanSoftware.KeylessAgent.Core.Resources.selectors.json"; + /// + /// The catalog version shipped with THIS build (bump whenever the embedded default + /// selectors change). A workspace file without the key reads as 0 - written by a + /// pre-versioning release - and is merged up to the embedded version on the next load. + /// + public const int EmbeddedCatalogVersion = 2; + + /// The version of the file this catalog was loaded from (0 = pre-versioning file). + public int CatalogVersion { get; init; } + /// Input textarea selectors. public string[] InputBox { get; init; } = []; @@ -54,6 +64,20 @@ internal sealed class SelectorsCatalog /// Uploaded-file chip selectors (the upload has landed in the UI). public string[] AttachmentChips { get; init; } = []; + /// + /// Remove-control selectors INSIDE an attachment chip (button / role=button / aria-label + /// remove|delete|close|cancel / an x icon). Unverified against the live UI — keyless + /// doctor prints the closest chip candidate when they match nothing. + /// + public string[] AttachmentRemove { get; init; } = []; + + /// + /// Failed-upload chip selectors (chip class containing error|failed|retry, error markers). + /// Unverified against the live UI — keyless doctor prints the closest chip candidate when + /// they match nothing. + /// + public string[] AttachmentFailed { get; init; } = []; + /// Conversation-too-long notice selectors. public string[] ChatLimitNotice { get; init; } = []; @@ -72,40 +96,253 @@ internal sealed class SelectorsCatalog /// /// The workspace directory. /// Workspace-relative selectors file name. + /// Merge even when the file's CatalogVersion is current. + /// When set, the merge logs which keys were refreshed. /// The catalog, or a KA_CONFIG_INVALID failure with a precise message. - public static async Task> LoadAsync(string workspaceRoot, string selectorsFileName, CancellationToken cancellationToken = default) + public static async Task> LoadAsync(string workspaceRoot, string selectorsFileName, CancellationToken cancellationToken = default, + bool forceRefresh = false, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null) + { + var merged = await MergeAsync(workspaceRoot, selectorsFileName, forceRefresh, loggerFactory, cancellationToken); + if (merged.IsFailure) + return Outcome.Failure(merged.Error); + return Outcome.Successful(merged.Data.Catalog); + } + + /// + /// The versioned loader: when the workspace file's CatalogVersion is older than the + /// embedded default's (or forceRefresh), the file is backed up as + /// selectors.json.v<oldVersion>.bak and MERGED - every key the user never + /// customized (its value matches ANY version ever shipped for that key, or the key is + /// missing entirely) is refreshed to the current embedded value; genuine customizations + /// survive untouched. + /// + /// The merged catalog and the names of the refreshed keys. + public static async Task RefreshedKeys)>> MergeAsync( + string workspaceRoot, string selectorsFileName, bool forceRefresh, + Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default) { var path = Path.Combine(workspaceRoot, selectorsFileName); var storage = ImanFileStorageFactory.CreateStorage(); + var log = loggerFactory is null + ? null + : new ImanSoftware.Logging.LoggerAdapter(loggerFactory); if (!storage.FileExists(path)) { var embedded = ReadEmbeddedDefault(); if (embedded.IsFailure) - return Outcome.Failure(embedded.Error); + return Outcome.Failure<(SelectorsCatalog, IReadOnlyList)>(embedded.Error); - var directory = storage.CreateDirectoryAsync(Path.GetDirectoryName(path)!); if ((await storage.CreateDirectoryAsync(Path.GetDirectoryName(path)!, cancellationToken)).IsFailure) - return Outcome.Failure(new OutcomeError( + return Outcome.Failure<(SelectorsCatalog, IReadOnlyList)>(new OutcomeError( $"Cannot create directory for {path}.", ErrorCodes.ConfigurationInvalid, OutcomeErrorType.Failure)); var write = await storage.WriteAllTextAsync(path, embedded.Data, cancellationToken); if (write.IsFailure) - return Outcome.Failure(new OutcomeError( + return Outcome.Failure<(SelectorsCatalog, IReadOnlyList)>(new OutcomeError( $"Cannot write default selectors to {path}: {write.Error.Message}", ErrorCodes.ConfigurationInvalid, OutcomeErrorType.Failure)); - return Deserialize(embedded.Data, path); + var fresh = Deserialize(embedded.Data, path); + return fresh.IsSuccess + ? Outcome.Successful((fresh.Data, (IReadOnlyList)Array.Empty())) + : Outcome.Failure<(SelectorsCatalog, IReadOnlyList)>(fresh.Error); } var text = await storage.ReadAllTextAsync(path, cancellationToken); if (text.IsFailure) - return Outcome.Failure(new OutcomeError( + return Outcome.Failure<(SelectorsCatalog, IReadOnlyList)>(new OutcomeError( $"Cannot read {path}: {text.Error.Message}", ErrorCodes.ConfigurationInvalid, OutcomeErrorType.Failure)); - return Deserialize(text.Data, path); + using var workspaceJson = ParseLoose(text.Data); + if (workspaceJson is null) + return Outcome.Failure<(SelectorsCatalog, IReadOnlyList)>(new OutcomeError( + $"{path} is not valid JSON.", ErrorCodes.ConfigurationInvalid, OutcomeErrorType.Validation)); + + using var embeddedJson = ParseLoose(ReadEmbeddedDefault().Data ?? "{}"); + if (embeddedJson is null) + return Outcome.Failure<(SelectorsCatalog, IReadOnlyList)>(new OutcomeError( + "The embedded default selectors resource is invalid.", ErrorCodes.ConfigurationInvalid, OutcomeErrorType.Problem)); + + var workspaceVersion = 0; + var wsVersion = FindWorkspaceKey(workspaceJson.RootElement, "CatalogVersion"); + if (wsVersion.HasValue && wsVersion.Value.TryGetInt32(out var wsVersionValue)) + { + workspaceVersion = wsVersionValue; + } + + if (!forceRefresh && workspaceVersion >= EmbeddedCatalogVersion) + { + var current = Deserialize(text.Data, path); + return current.IsSuccess + ? Outcome.Successful((current.Data, (IReadOnlyList)Array.Empty())) + : Outcome.Failure<(SelectorsCatalog, IReadOnlyList)>(current.Error); + } + + // --- Merge: refresh never-customized keys, preserve genuine customizations --- + // Key matching is CASE-INSENSITIVE (the tolerant parser accepts lowercase keys); + // workspace-only keys (not in the embedded default) are carried over untouched. + static JsonElement? FindWorkspaceKey(JsonElement workspace, string name) + { + foreach (var property in workspace.EnumerateObject()) + { + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + return property.Value.Clone(); + } + + return null; + } + + var merged = new Dictionary(); + var refreshedKeys = new List(); + foreach (var workspaceProperty in workspaceJson.RootElement.EnumerateObject()) + { + var isEmbeddedKey = embeddedJson.RootElement.TryGetProperty(workspaceProperty.Name, out _) + || embeddedJson.RootElement.EnumerateObject() + .Any(e => string.Equals(e.Name, workspaceProperty.Name, StringComparison.OrdinalIgnoreCase)); + if (workspaceProperty.Name.Equals("CatalogVersion", StringComparison.OrdinalIgnoreCase) || isEmbeddedKey) + continue; + merged[workspaceProperty.Name] = workspaceProperty.Value.Clone(); + } + + foreach (var key in embeddedJson.RootElement.EnumerateObject()) + { + if (key.Name == "CatalogVersion" || key.Value.ValueKind != JsonValueKind.Array) + continue; + + var canonicalEmbedded = Canonical(key.Value); + var workspaceValue = FindWorkspaceKey(workspaceJson.RootElement, key.Name); + if (workspaceValue is null) + { + merged[key.Name] = key.Value.Clone(); // a key added in a later release + refreshedKeys.Add(key.Name); + continue; + } + + var canonicalWorkspace = Canonical(workspaceValue.Value); + if (canonicalWorkspace == canonicalEmbedded) + { + merged[key.Name] = workspaceValue.Value.Clone(); // already current + continue; + } + + var isShippedValue = ShippedValues.TryGetValue(key.Name, out var shipped) + && shipped.Any(variant => JsonSerializer.Serialize(variant) == canonicalWorkspace); + if (isShippedValue) + { + // The workspace value is identical to SOME version ever shipped for this key: + // the user never customized it, so the new embedded value is safe to take. + merged[key.Name] = key.Value.Clone(); + refreshedKeys.Add(key.Name); + continue; + } + + merged[key.Name] = workspaceValue.Value.Clone(); // deliberate customization - preserve + } + + var mergedJson = ToIndentedJson(merged, EmbeddedCatalogVersion); + var backupWrite = await storage.WriteAllTextAsync($"{path}.v{workspaceVersion}.bak", text.Data, cancellationToken); + if (backupWrite.IsFailure) + return Outcome.Failure<(SelectorsCatalog, IReadOnlyList)>(new OutcomeError( + $"Cannot back up {path} before the merge: {backupWrite.Error.Message}", + ErrorCodes.ConfigurationInvalid, OutcomeErrorType.Failure)); + + var mergedWrite = await storage.WriteAllTextAsync(path, mergedJson, cancellationToken); + if (mergedWrite.IsFailure) + return Outcome.Failure<(SelectorsCatalog, IReadOnlyList)>(new OutcomeError( + $"Cannot write the merged selectors to {path}: {mergedWrite.Error.Message}", + ErrorCodes.ConfigurationInvalid, OutcomeErrorType.Failure)); + + log?.LogInformation( + "selectors.json upgraded from CatalogVersion {From} to {To}; refreshed keys: {Keys}. Backup: {Backup}.", + workspaceVersion, EmbeddedCatalogVersion, + refreshedKeys.Count == 0 ? "(none - only the version marker changed)" : string.Join(", ", refreshedKeys), + $"{selectorsFileName}.v{workspaceVersion}.bak"); + + var catalog = Deserialize(mergedJson, path); + return catalog.IsSuccess + ? Outcome.Successful((catalog.Data, (IReadOnlyList)refreshedKeys)) + : Outcome.Failure<(SelectorsCatalog, IReadOnlyList)>(catalog.Error); + } + + private static JsonDocument? ParseLoose(string? json) + { + try + { + return JsonDocument.Parse(json ?? "{}", new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }); + } + catch (JsonException) + { + return null; + } + } + + /// Whitespace-normalized JSON text, so hand-reformatting still compares equal. + private static string Canonical(JsonElement element) => JsonSerializer.Serialize(element); + + private static string ToIndentedJson(Dictionary merged, int catalogVersion) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true })) + { + writer.WriteStartObject(); + writer.WriteNumber("CatalogVersion", catalogVersion); + foreach (var (key, value) in merged.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + writer.WritePropertyName(key); + value.WriteTo(writer); + } + + writer.WriteEndObject(); + } + + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); } + /// + /// Every JSON array EVER shipped for a key (generated once from the resource's git + /// history, canonical System.Text.Json form). When the workspace value matches one of + /// these, it was never customized and the merge refreshes it to the current embedded + /// value; keys missing here have only ever shipped one value, so any difference is a + /// user customization. + /// PATTERN: when you change an embedded selector and bump EmbeddedCatalogVersion, add + /// the OUTGOING value to this key's list (and to the embedded resource first, of course). + /// + private static readonly Dictionary ShippedValues = new(StringComparer.OrdinalIgnoreCase) + { + ["DeepThinkToggle"] = + [ + [".ds-toggle-button:has-text(\"DeepThink\")", "[role=\"button\"][aria-pressed]:has-text(\"DeepThink\")"], + [".ds-toggle-button:has(span)", "[role=\"button\"][aria-pressed]", ".ds-toggle-button:has-text(\"DeepThink\")"] + ], + ["MessageCopyButton"] = + [ + [".ds-message [role=\"button\"][aria-label=\"Copy\"]", ".ds-message .ds-flex [role=\"button\"]:first-child", ".ds-message [role=\"button\"]:has-text(\"Copy\")"] + ], + ["MessageItems"] = + [ + [".ds-virtual-list [data-virtual-list-item-key]", ".ds-message"] + ], + ["SearchToggle"] = + [ + [".ds-toggle-button:has-text(\"Search\")", "[role=\"button\"][aria-pressed]:has-text(\"Search\")"], + [".ds-toggle-button:has(span)", "[role=\"button\"][aria-pressed]", ".ds-toggle-button:has-text(\"Search\")"] + ], + ["SendButton"] = + [ + ["div:has(> input[type=\"file\"]) [role=\"button\"].ds-button--filled", "[role=\"button\"].ds-button--filled.ds-button--circle", "button[type=\"submit\"]"], + ["div:has(> textarea) ~ * [role=\"button\"].ds-button--icon", "[role=\"button\"].ds-button--iconLabelPrimary", "[role=\"button\"].ds-button--filled.ds-button--circle", "div:has(> input[type=\"file\"]) [role=\"button\"].ds-button--filled", "button[type=\"submit\"]"] + ], + ["StopIndicator"] = + [ + ["[role=\"button\"][aria-label=\"Stop\"]", "[role=\"button\"]:has-text(\"Stop\")", ".ds-button--filled:has-text(\"Stop\")"] + ], + }; + private static Outcome Deserialize(string json, string path) { try diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Configuration/ConfigurationService.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Configuration/ConfigurationService.cs index b9a0da3..f7f8110 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Configuration/ConfigurationService.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Configuration/ConfigurationService.cs @@ -3,6 +3,7 @@ using ImanSoftware.FileStorage; using ImanSoftware.KeylessAgent.Abstractions.Models; using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration; +using ImanSoftware.KeylessAgent.Abstractions.Models.Enums; using ImanSoftware.KeylessAgent.Abstractions.Services; using ImanSoftware.Outcomes; using ImanSoftware.Serialization; @@ -18,15 +19,19 @@ internal sealed class ConfigurationService : IConfigurationService private const string ConfigFileName = "keyless.json"; private readonly IFileStorage _storage; + private readonly ImanSoftware.Logging.ILoggerAdapter? _log; public ConfigurationService() : this(ImanFileStorageFactory.CreateStorage()) { } - internal ConfigurationService(IFileStorage storage) + internal ConfigurationService(IFileStorage? storage = null, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null) { - _storage = storage; + _storage = storage ?? ImanFileStorageFactory.CreateStorage(); + _log = loggerFactory is null + ? null + : new ImanSoftware.Logging.LoggerAdapter(loggerFactory); } /// @@ -51,18 +56,20 @@ public async Task> LoadAsync(string workspaceRoot) if (!_storage.FileExists(path)) { + // A fresh file is born at the CURRENT schema version; writing a versionless + // default here would make the very next load "migrate" it pointlessly. var serializer = CreateSerializer(); - var defaults = serializer.Serialize(new KeylessOptions()); + var defaults = KeylessOptions.CreateDefault(); var mkdir = await _storage.CreateDirectoryAsync(Path.GetDirectoryName(path)!); if (mkdir.IsFailure) return Outcome.Failure(mkdir.Error); - var write = await _storage.WriteAllTextAsync(path, defaults); + var write = await _storage.WriteAllTextAsync(path, serializer.Serialize(defaults)); if (write.IsFailure) return Outcome.Failure(write.Error); - return Outcome.Successful(new KeylessOptions()); + return Outcome.Successful(defaults); } var read = await _storage.ReadAllTextAsync(path); @@ -91,7 +98,134 @@ public async Task> LoadAsync(string workspaceRoot) if (validation.IsFailure) return Outcome.Failure(validation.Error); - return Outcome.Successful(options); + var (migratedOptions, migrated) = await MigrateIfStaleAsync(path, read.Data, options); + LastLoadMigratedSchema = migrated; + + var (upgradedOptions, upgradedFields) = await UpgradeKnownStaleDefaultsAsync(path, read.Data, migratedOptions); + LastLoadUpgradedFields = upgradedFields; + return Outcome.Successful(upgradedOptions); + } + + /// + public bool LastLoadMigratedSchema { get; private set; } + + /// + /// The known-stale-default upgrades applied during the LAST load ("Field: old -> new"), + /// empty when none ran. A full schema migration already rewrites every value, so this + /// only ever fires for files that are already at the current SchemaVersion. + /// + public IReadOnlyList LastLoadUpgradedFields { get; private set; } = Array.Empty(); + + /// + /// One entry per field whose SHIPPED default changed after users already wrote their + /// keyless.json: since CreateDefault() serializes every field explicitly, the old + /// default literal sits in existing files forever and silently overrides the new code + /// default. An entry upgrades the loaded value ONLY when it exactly matches a known old + /// default — anything else means the user deliberately set it and is left alone. + /// PATTERN for future default changes: when you change a default in KeylessOptions, + /// add ONE line here (old value(s) -> new default) so existing workspaces pick it up. + /// Run on EVERY load, orthogonal to the SchemaVersion-gated migration above. String + /// comparison is OrdinalIgnoreCase — case variants of a shipped default (the tolerant + /// parser accepts them) are still that default, not a user customization. + /// + private sealed record KnownStaleDefault( + string FieldPath, + object? OldDefault, + object? NewDefault, + Func Read, + Func With); + + private static readonly KnownStaleDefault[] KnownStaleDefaults = + [ + // The window default changed twice (Minimized -> Offscreen -> TinyCorner); both old + // literals throttle Chrome's renderer and truncate replies (turn-17/18 evidence). + new("Browser.WindowMode", "Minimized", "TinyCorner", + o => o.Browser.WindowMode, + (o, v) => o with { Browser = o.Browser with { WindowMode = (string)v! } }), + new("Browser.WindowMode", "Offscreen", "TinyCorner", + o => o.Browser.WindowMode, + (o, v) => o with { Browser = o.Browser with { WindowMode = (string)v! } }), + ]; + + private static bool MatchesOldDefault(object? current, object? oldDefault) + => oldDefault is string oldString + ? current is string currentString && string.Equals(currentString, oldString, StringComparison.OrdinalIgnoreCase) + : Equals(current, oldDefault); + + /// + /// Upgrades known-stale default literals (see ) on every + /// load. Backs up the raw file as keyless.json.pre-defaults-upgrade.bak (a suffix that + /// cannot collide with the real schema-version backups) and rewrites the file with the + /// upgraded options — every OTHER loaded value (user customizations included) survives, + /// because the options object came from the user's file. + /// + private async Task<(KeylessOptions Options, IReadOnlyList Upgraded)> UpgradeKnownStaleDefaultsAsync( + string path, string rawContents, KeylessOptions loaded) + { + var upgrades = new List(); + foreach (var entry in KnownStaleDefaults) + { + var current = entry.Read(loaded); + if (!MatchesOldDefault(current, entry.OldDefault) || Equals(current, entry.NewDefault)) + continue; + + loaded = entry.With(loaded, entry.NewDefault); + upgrades.Add($"{entry.FieldPath}: {current} -> {entry.NewDefault}"); + } + + if (upgrades.Count == 0) + return (loaded, Array.Empty()); + + var backupWrite = await _storage.WriteAllTextAsync($"{path}.pre-defaults-upgrade.bak", rawContents); + if (backupWrite.IsFailure) + return (loaded, Array.Empty()); // never fail a load over the upgrade + + var write = await _storage.WriteAllTextAsync(path, CreateSerializer().Serialize(loaded)); + if (write.IsFailure) + return (loaded, Array.Empty()); + + // One informational line per upgraded field, naming exactly what changed and why. + foreach (var upgrade in upgrades) + _log?.LogInformation("keyless.json defaults upgrade: {Upgrade} (old shipped default)", upgrade); + + return (loaded, upgrades); + } + + /// + /// Migrates a pre-schema-version keyless.json to the current defaults: backs up the raw + /// file as keyless.json.v{version}.bak, preserves the four most common user + /// customizations (Mode, ReplyTimeoutSeconds, ChromeDebugPort, Allowlist) and replaces + /// every other value — including the stale timeout defaults — with the current ones. + /// + private async Task<(KeylessOptions Options, bool Migrated)> MigrateIfStaleAsync( + string path, string rawContents, KeylessOptions loaded) + { + var effectiveVersion = loaded.SchemaVersion ?? 1; + if (effectiveVersion >= KeylessOptions.CurrentSchemaVersion) + return (loaded, false); + + var backupPath = $"{path}.v{effectiveVersion}.bak"; + var backupWrite = await _storage.WriteAllTextAsync(backupPath, rawContents); + if (backupWrite.IsFailure) + return (loaded, false); + + var fresh = KeylessOptions.CreateDefault(); + if (loaded.Mode != ApprovalMode.Ask) + fresh = fresh with { Mode = loaded.Mode }; + if (loaded.Browser.ReplyTimeoutSeconds != 300) + fresh = fresh with { Browser = fresh.Browser with { ReplyTimeoutSeconds = loaded.Browser.ReplyTimeoutSeconds } }; + if (loaded.Browser.ChromeDebugPort != 9222) + fresh = fresh with { Browser = fresh.Browser with { ChromeDebugPort = loaded.Browser.ChromeDebugPort } }; + if (!loaded.Commands.Allowlist.SequenceEqual( + new[] { "dotnet", "git", "npm", "node", "python" }, StringComparer.OrdinalIgnoreCase)) + fresh = fresh with { Commands = fresh.Commands with { Allowlist = loaded.Commands.Allowlist } }; + + var serializer = CreateSerializer(); + var write = await _storage.WriteAllTextAsync(path, serializer.Serialize(fresh)); + if (write.IsFailure) + return (loaded, false); + + return (fresh, true); } /// @@ -124,6 +258,8 @@ internal static Outcome Validate(KeylessOptions options) problems.Add("Browser.SendConfirmSeconds must be at least 1."); if (options.Browser.UploadWaitSeconds < 5) problems.Add("Browser.UploadWaitSeconds must be at least 5."); + if (options.Browser.TruncationBudgetSeconds < 5) + problems.Add("Browser.TruncationBudgetSeconds must be at least 5."); if (options.Browser.MinDelayMs < 0 || options.Browser.MaxDelayMs < options.Browser.MinDelayMs) problems.Add("Browser delays must satisfy 0 <= MinDelayMs <= MaxDelayMs."); if (options.Browser.MaxMessagesPerMinute < 1) diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/BackupService.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/BackupService.cs index 6a2c4da..1c4657e 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/BackupService.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/BackupService.cs @@ -115,6 +115,22 @@ internal async Task RecordCreateAsync(string workspaceRoot, string crea return await AppendJournalAsync(workspaceRoot, stamp, new JournalEntry("create", Path: relative.Replace('\\', '/'))); } + /// + /// Journals a DIRECTORY creation into a fresh batch (undo removes the folder while it + /// is empty). Best-effort like every journal write. + /// + internal async Task RecordCreateDirectoryAsync(string workspaceRoot, string createdFullPath) + { + var relative = Path.GetRelativePath(workspaceRoot, createdFullPath); + var stamp = _time.Now.ToUniversalTime().ToString("yyyyMMdd-HHmmss-fff", System.Globalization.CultureInfo.InvariantCulture); + var batchDir = Path.Combine(workspaceRoot, BackupFolderName, stamp); + var mkdir = await _storage.CreateDirectoryAsync(batchDir); + if (mkdir.IsFailure) + return mkdir; + + return await AppendJournalAsync(workspaceRoot, stamp, new JournalEntry("create-dir", Path: relative.Replace('\\', '/'))); + } + /// public async Task>> UndoLatestAsync(string workspaceRoot) { @@ -173,6 +189,20 @@ public async Task>> UndoLatestAsync(string workspa if (string.IsNullOrWhiteSpace(affected)) continue; + // A created directory is removed while EMPTY; anything the user or a + // later command added keeps the folder. + if (entry.Op == "create-dir") + { + var dirTarget = sandbox.Resolve(affected); + if (dirTarget.IsFailure || !_storage.DirectoryExists(dirTarget.Data)) + continue; + + var removeEmpty = await _storage.DeleteDirectoryAsync(dirTarget.Data, recursive: false); + if (removeEmpty.IsSuccess) + restored.Add($"removed: {affected}"); + continue; + } + var target = sandbox.Resolve(affected); if (target.IsFailure || !_storage.FileExists(target.Data)) continue; diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/MkdirExecutor.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/MkdirExecutor.cs new file mode 100644 index 0000000..7793d56 --- /dev/null +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/MkdirExecutor.cs @@ -0,0 +1,82 @@ +using ImanSoftware.FileStorage; +using ImanSoftware.KeylessAgent.Abstractions.Models; +using ImanSoftware.KeylessAgent.Abstractions.Models.Enums; +using ImanSoftware.KeylessAgent.Abstractions.Models.Execution; +using ImanSoftware.KeylessAgent.Abstractions.Models.Parser; +using ImanSoftware.KeylessAgent.Abstractions.Services; +using ImanSoftware.Outcomes; +using ImanSoftware.KeylessAgent.Core.Implementation.Git; + +namespace ImanSoftware.KeylessAgent.Core.Implementation.Execution; + +/// +/// Creates a folder (and missing parents). Sandboxed like the other file commands; never +/// confirmed (idempotent, harmless); journaled so keyless undo removes it while empty. +/// +internal sealed class MkdirExecutor : ICommandExecutor +{ + private readonly IFileStorage _storage; + private readonly IBranchGuard _branchGuard; + private readonly BackupService _backup; + + public MkdirExecutor() + : this(ImanFileStorageFactory.CreateStorage(), new BranchGuard()) + { + } + + internal MkdirExecutor(IFileStorage storage, IBranchGuard branchGuard, BackupService? backup = null) + { + _storage = storage; + _branchGuard = branchGuard; + _backup = backup ?? new BackupService(); + } + + /// + public CommandKind Kind => CommandKind.Mkdir; + + /// + public async Task ExecuteAsync(AgentScriptCommand command, AgentExecutionContext context) + { + var mkdir = (MkdirCommand)command; + + var sandbox = new PathSandbox(context.WorkspaceRoot); + var resolve = sandbox.Resolve(mkdir.Path); + if (resolve.IsFailure) + return CommandExecutionResult.Fail(command, resolve.Error.Code, resolve.Error.Message); + + // NO-GIT MODE and DRY RUN: no branch protection (no git, or nothing is mutated). + if (!context.NoGitMode && !context.DryRun) + { + var guard = await _branchGuard.EnsureModifiableAsync(context.WorkspaceRoot, context.Options.Git); + if (guard.IsFailure) + return CommandExecutionResult.Fail(command, guard.Error.Code, guard.Error.Message); + } + + // Conflict ONLY when a FILE with that name exists; an existing folder is fine + // (CreateDirectory is idempotent). + if (_storage.FileExists(resolve.Data)) + { + return CommandExecutionResult.Fail(command, ErrorCodes.FileAlreadyExists, + $"A file already exists at: {mkdir.Path}. Refusing to shadow it with a folder."); + } + + if (context.DryRun) + return NewFileExecutor.DryRunResult(command); + + var directory = await _storage.CreateDirectoryAsync(resolve.Data, context.CancellationToken); + if (directory.IsFailure) + return CommandExecutionResult.Fail(command, ErrorCodes.FileIoFailure, directory.Error.Message); + + // Best-effort journal: keyless undo removes the created folder while it is empty. + await _backup.RecordCreateDirectoryAsync(context.WorkspaceRoot, resolve.Data); + + return new CommandExecutionResult + { + Number = command.Number, + Kind = command.Kind, + Target = command.Target, + Status = CommandStatus.Ok, + Output = $"Created folder {mkdir.Path}.", + }; + } +} diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/ProcessRunner.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/ProcessRunner.cs index 0c2b363..3a8ba01 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/ProcessRunner.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/ProcessRunner.cs @@ -29,7 +29,30 @@ internal sealed record ProcessResult(int ExitCode, string Output, string Error, /// Runs external processes (git, dotnet, the snapshot bat files) with output capture, /// a hard timeout and full process-tree kill. Process execution deliberately does not go through IFileStorage. /// -internal sealed class ProcessRunner +internal interface IProcessRunner +{ + /// + /// Runs a process to completion without a console window. + /// + /// The executable to run. + /// The argument string. + /// The working directory for the process. + /// Wall-clock timeout; the process tree is killed when exceeded. + /// Cancellation token. + /// The captured result (exit code, output, error, duration, timeout/cancel reason). + Task RunAsync( + string fileName, + string arguments, + string workingDirectory, + int timeoutSeconds, + CancellationToken cancellationToken = default); +} + +/// +/// Real implementation: System.Diagnostics.Process with output capture, a hard timeout and a +/// full process-tree kill on timeout or cancellation. +/// +internal sealed class ProcessRunner : IProcessRunner { /// /// Runs a process to completion without a console window. @@ -73,8 +96,11 @@ public async Task RunAsync( // Close stdin immediately so batch files that end with `pause` can never hang waiting for a key. process.StandardInput.Close(); - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + // The readers deliberately take NO cancellation token: on timeout/cancel the process + // is killed, which closes the pipes and ends the readers — cancelling the readers + // themselves would throw away the partial output printed before the kill. + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/RunCommandExecutor.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/RunCommandExecutor.cs index 250bdfe..da146eb 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/RunCommandExecutor.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/RunCommandExecutor.cs @@ -4,6 +4,7 @@ using ImanSoftware.KeylessAgent.Abstractions.Models.Execution; using ImanSoftware.KeylessAgent.Abstractions.Models.Parser; using ImanSoftware.KeylessAgent.Abstractions.Services; +using ImanSoftware.KeylessAgent.Core.Implementation.Workspace; using ImanSoftware.Outcomes; namespace ImanSoftware.KeylessAgent.Core.Implementation.Execution; @@ -14,20 +15,25 @@ namespace ImanSoftware.KeylessAgent.Core.Implementation.Execution; /// internal sealed class RunCommandExecutor : ICommandExecutor { + private static readonly HashSet KnownTools = new(StringComparer.OrdinalIgnoreCase) + { "git", "dotnet", "node", "npm", "python", "py", "python3" }; + private readonly ICommandPolicy _policy; private readonly ICommandRiskClassifier _riskClassifier; - private readonly ProcessRunner _runner; + private readonly IProcessRunner _runner; + private readonly IEnvironmentTools? _environmentTools; public RunCommandExecutor() : this(new CommandPolicy(), new CommandRiskClassifier(), new ProcessRunner()) { } - internal RunCommandExecutor(ICommandPolicy policy, ICommandRiskClassifier riskClassifier, ProcessRunner runner) + internal RunCommandExecutor(ICommandPolicy policy, ICommandRiskClassifier riskClassifier, IProcessRunner runner, IEnvironmentTools? environmentTools = null) { _policy = policy; _riskClassifier = riskClassifier; _runner = runner; + _environmentTools = environmentTools; } /// @@ -55,7 +61,33 @@ public async Task ExecuteAsync(AgentScriptCommand comman if (context.DryRun) return NewFileExecutor.DryRunResult(command); - var (program, arguments) = SplitCommandLine(run.CommandLine); + // The program token is kept for error messages only — the line itself runs through + // the shell so built-ins, &&/|| chains and redirects work as the model wrote them. + var (program, _) = SplitCommandLine(run.CommandLine); + var (shellFile, shellArguments) = ShellInvocationFor(run.CommandLine, OperatingSystem.IsWindows()); + + // Fail fast: a tool the environment lists as Missing is never started and never + // retried — the model must use the file commands or ask the user to install it. + // A cache older than 1 h re-probes that one tool first (cheap) to stay current. + if (_environmentTools is not null && KnownTools.Contains(program)) + { + var toolStatus = await _environmentTools.GetToolStatusAsync( + context.WorkspaceRoot, program.ToLowerInvariant(), context.CancellationToken); + if (toolStatus.State is ToolStates.Missing or ToolStates.StoreAlias) + { + return new CommandExecutionResult + { + Number = command.Number, + Kind = command.Kind, + Target = command.Target, + Status = CommandStatus.Error, + ErrorCode = ErrorCodes.ToolMissing, + FailureReason = "tool_missing", + Tier = assessment.Data.Tier, + ErrorMessage = $"'{program}' is not installed on this machine (see Environment). Do not retry it. Use the file commands, or ask the user to install it.", + }; + } + } // Approval policy per mode and tier. var autoApproved = false; @@ -108,11 +140,17 @@ public async Task ExecuteAsync(AgentScriptCommand comman } var result = await _runner.RunAsync( - program, arguments, context.WorkspaceRoot, + shellFile, shellArguments, context.WorkspaceRoot, context.Options.Commands.TimeoutSeconds, context.CancellationToken); if (result.NotStarted) { + // The program is not on the machine although the cache said it is installed — + // reality wins: re-probe the tool so the fail-fast rule stays current (or the + // reverse: a cache miss gets corrected too). + if (_environmentTools is not null && KnownTools.Contains(program)) + await _environmentTools.InvalidateToolAsync(context.WorkspaceRoot, program.ToLowerInvariant(), context.CancellationToken); + return new CommandExecutionResult { Number = command.Number, @@ -122,7 +160,7 @@ public async Task ExecuteAsync(AgentScriptCommand comman ErrorCode = ErrorCodes.CommandNotFound, FailureReason = "command_not_found", Tier = assessment.Data.Tier, - ErrorMessage = $"'{program}' was not found: it is not installed or not on PATH.", + ErrorMessage = NotAvailableMessage(program), }; } @@ -166,9 +204,13 @@ public async Task ExecuteAsync(AgentScriptCommand comman var notFoundReason = IsCommandNotFound(result); var failureReason = notFoundReason ? "command_not_found" : "non_zero_exit"; var message = notFoundReason - ? $"'{program}' was not found: it is not installed or not on PATH." + ? NotAvailableMessage(program) : $"Command exited with code {result.ExitCode}."; + // Same auto-invalidation as NotStarted: reality wins over the cache. + if (notFoundReason && _environmentTools is not null && KnownTools.Contains(program)) + await _environmentTools.InvalidateToolAsync(context.WorkspaceRoot, program.ToLowerInvariant(), context.CancellationToken); + return new CommandExecutionResult { Number = command.Number, @@ -198,6 +240,22 @@ public async Task ExecuteAsync(AgentScriptCommand comman }; } + /// + /// The failure message for a program the shell could not run: first token of the command + /// line plus the actionable hint (shell built-ins keep working under the shell wrapper). + /// + internal static string NotAvailableMessage(string program) + => $"'{program}' is not available: it is not installed, not on PATH or not an executable (shell built-ins work). Check the Environment line."; + + /// + /// Wraps one command line for the platform shell: cmd.exe /d /s /c on Windows (the whole + /// line auto-quoted; /s strips the outer quotes, /d skips AutoRun), /bin/sh -c elsewhere. + /// + internal static (string FileName, string Arguments) ShellInvocationFor(string commandLine, bool isWindows) + => isWindows + ? ("cmd.exe", $"/d /s /c \"{commandLine}\"") + : ("/bin/sh", $"-c \"{commandLine.Replace("\"", "\\\"")}\""); + /// /// Whether a non-zero exit means the program itself does not exist: cmd's 9009, shell 127, /// or the classic "not recognized" messages. diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/WorkspaceCommandExecutor.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/WorkspaceCommandExecutor.cs index 6ac5c51..3c693cd 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/WorkspaceCommandExecutor.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Execution/WorkspaceCommandExecutor.cs @@ -3,6 +3,7 @@ using ImanSoftware.KeylessAgent.Abstractions.Models.Execution; using ImanSoftware.KeylessAgent.Abstractions.Models.Parser; using ImanSoftware.KeylessAgent.Abstractions.Services; +using ImanSoftware.KeylessAgent.Core.Implementation.Workspace; namespace ImanSoftware.KeylessAgent.Core.Implementation.Execution; @@ -22,11 +23,12 @@ public WorkspaceCommandExecutor() new EditFileExecutor(), new DeleteFileExecutor(), new ReadFileExecutor(), - new RunCommandExecutor(), + new RunCommandExecutor(new CommandPolicy(), new CommandRiskClassifier(), new ProcessRunner(), new EnvironmentProbe()), new AskExecutor(), new CommitExecutor(), new NextExecutor(), new MoveFileExecutor(), + new MkdirExecutor(), new DoneExecutor()) { } diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Parsing/AgentScriptParser.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Parsing/AgentScriptParser.cs index 58469e6..dadb18b 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Parsing/AgentScriptParser.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Parsing/AgentScriptParser.cs @@ -17,6 +17,7 @@ internal sealed partial class AgentScriptParser : IAgentScriptParser { private const string EofKeyword = "@@eof"; private const string EndKeyword = "@@end"; + private const string CommandPrefix = "@@"; private static readonly Dictionary CommandsByKeyword = Enum.GetValues().ToDictionary(k => k.GetDescription(), k => k, StringComparer.OrdinalIgnoreCase); @@ -39,6 +40,7 @@ public Outcome Parse(string reply) var lines = SplitLines(reply); lines = StripSurroundingCodeFence(lines); + lines = RepairKnownCorruptions(lines); var problems = new List(); var commands = new List(); @@ -196,6 +198,28 @@ public Outcome Parse(string reply) break; } + // A payload marker chained with a raw @@ command on one line + // ("...CONTENT>>> @@end @@newfile x <<= 0) + { + var closeMarkerIndex = trimmed.IndexOf(expectedClose, StringComparison.Ordinal); + var openMarkerIndex = trimmed.IndexOf("<<<", StringComparison.Ordinal); + if ((closeMarkerIndex >= 0 && commandIndex > closeMarkerIndex) + || (openMarkerIndex >= 0 && commandIndex > openMarkerIndex)) + { + problems.Add(closeMarkerIndex >= 0 + ? new FormatProblem(lineNumber, ErrorCodes.ParseBadMarker, + $"Payload close marker \"{expectedClose}\" and a command both appear on one line — each marker must be ALONE on its own line.") + : new FormatProblem(lineNumber, ErrorCodes.ParseBadMarker, + "A payload marker (\"<<< …\") and a command both appear on one line — each marker must be ALONE on its own line.")); + index = lines.Count; // stop processing the rest of this reply + break; + } + } // Lenient one-line payload: <<>> for non-CONTENT // sections (MSG/FIND/REPLACE/CMD/TO). Normalized to multi-line. @@ -245,9 +269,43 @@ public Outcome Parse(string reply) } /// - /// Converts a finished block into its command record, adding problems for missing - /// arguments, missing payloads and unexpected payloads. + /// Strips ONE outer Markdown code fence from a CONTENT payload: the first non-blank line + /// must open with three backticks (optionally followed by a language identifier) and the + /// last non-blank line must be exactly a closing fence of backticks. Anything else is + /// returned unchanged, so file content that legitimately contains fences deeper inside + /// is never touched. /// + private static string StripOuterCodeFence(string content) + { + if (string.IsNullOrEmpty(content)) + return content; + + var lf = Convert.ToChar(10); + var crlf = string.Concat(Convert.ToChar(13), lf); + var normalized = content.Replace(crlf, new string(lf, 1)); + var lines = normalized.Split(lf); + if (lines.Length < 2) + return content; + + var first = 0; + while (first < lines.Length && lines[first].Trim().Length == 0) first++; + var last = lines.Length - 1; + while (last >= 0 && lines[last].Trim().Length == 0) last--; + + if (first >= last) + return content; + + var opener = lines[first].TrimStart(); + if (!opener.StartsWith("```", StringComparison.Ordinal)) + return content; + + var closer = lines[last].Trim(); + if (closer.Length < 3 || !closer.All(c => c == '`')) + return content; + + return string.Join(lf.ToString(), lines[(first + 1)..last]); + } + private static void BuildCommand(BlockContext block, List problems, List commands) { var problemCountBefore = problems.Count; @@ -274,6 +332,17 @@ private static void BuildCommand(BlockContext block, List problem // an empty MSG are both valid); everything not required and not optional is rejected. var optional = block.Kind == CommandKind.Next ? new[] { SectionNames.Msg } : Array.Empty(); + // "@@say hello" is unambiguously "@@say" with MSG = "hello": none of these commands + // use the argument slot for anything else, so a bare argument becomes the MSG payload. + // This accepts the one-line say shape that markdown-collapsed replies produce. + if (block.Kind is CommandKind.Say or CommandKind.Done or CommandKind.Next + or CommandKind.Ask or CommandKind.Commit + && !string.IsNullOrWhiteSpace(block.Argument) + && !block.Sections.ContainsKey(SectionNames.Msg)) + { + block.Sections[SectionNames.Msg] = block.Argument.Trim(); + } + foreach (var extra in block.Sections.Keys.Where(k => !required.Contains(k) && !optional.Contains(k))) { problems.Add(new FormatProblem(block.StartLine, ErrorCodes.ParseUnexpectedPayload, @@ -281,7 +350,8 @@ private static void BuildCommand(BlockContext block, List problem } var needsArgument = block.Kind is CommandKind.NewFile or CommandKind.UpdateFile - or CommandKind.EditFile or CommandKind.DeleteFile or CommandKind.ReadFile or CommandKind.MoveFile; + or CommandKind.EditFile or CommandKind.DeleteFile or CommandKind.ReadFile + or CommandKind.MoveFile or CommandKind.Mkdir; if (needsArgument && string.IsNullOrWhiteSpace(block.Argument)) { @@ -289,6 +359,17 @@ private static void BuildCommand(BlockContext block, List problem $"Command \"{block.Kind.GetDescription()}\" needs a path after the keyword.")); } + // "@@say hello" is unambiguously "@@say" with MSG = "hello": none of these control + // commands use the argument slot for anything else, so a bare argument becomes the + // MSG payload. This accepts the one-line say shape that collapsed replies produce. + if (block.Kind is CommandKind.Say or CommandKind.Done or CommandKind.Next + or CommandKind.Ask or CommandKind.Commit + && !string.IsNullOrWhiteSpace(block.Argument) + && !block.Sections.ContainsKey(SectionNames.Msg)) + { + block.Sections[SectionNames.Msg] = block.Argument.Trim(); + } + if (problems.Count > problemCountBefore) { // The block is malformed; nothing to add — numbering continues with the next block. @@ -296,6 +377,19 @@ private static void BuildCommand(BlockContext block, List problem } var payload = block.Sections; + + // The outer code fence is purely cosmetic markdown: DeepSeek renders an unfenced + // CONTENT payload as one collapsed paragraph, so models wrap file content in a + // ``` fence. Its presence or absence does not change which file is written or its + // content, so it is stripped before the command record is built. + if (block.Kind is CommandKind.NewFile or CommandKind.UpdateFile + && block.Sections.TryGetValue(SectionNames.Content, out var rawContent)) + { + var stripped = StripOuterCodeFence(rawContent); + if (!ReferenceEquals(stripped, rawContent) && stripped != rawContent) + block.Sections[SectionNames.Content] = stripped; + } + string? onError = null; var argText = block.Argument; if (block.Kind == CommandKind.RunCommand && argText.StartsWith("on_error=", StringComparison.OrdinalIgnoreCase)) @@ -329,6 +423,7 @@ private static void BuildCommand(BlockContext block, List problem CommandKind.Done => new DoneCommand(Payload(payload, SectionNames.Msg)), CommandKind.Next => new NextCommand(Payload(payload, SectionNames.Msg)), CommandKind.MoveFile => new MoveFileCommand(block.Argument, Payload(payload, SectionNames.To)), + CommandKind.Mkdir => new MkdirCommand(block.Argument), _ => throw new NotSupportedException($"Unhandled command kind {block.Kind}."), }; @@ -400,6 +495,83 @@ private static List SplitLines(string text) return lines; } + /// + /// Repairs the two corruptions observed in real turn logs, where the chat's markdown + /// renderer collapses the model's newlines before the parser sees them. Only patterns + /// that CANNOT be valid AgentScript as-is are repaired: a payload close marker chained + /// with a @@ command on one line ("hello CONTENT>>> @@end"), and @@end chained with a + /// @@ command on one line. Both are split into two lines; everything else is copied + /// unchanged, so legitimate content (e.g. a line reading "a CONTENT>>> b") is never + /// corrupted by this recovery. + /// + /// + /// Repairs the known corruptions to a FIXPOINT (at most 3 passes): chains like + /// "CONTENT>>> @@end @@newfile x << + internal static List RepairKnownCorruptions(List lines) + { + var current = lines; + for (var pass = 0; pass < 3; pass++) + { + var repaired = RepairPass(current); + if (repaired.Count == current.Count && repaired.SequenceEqual(current, StringComparer.Ordinal)) + return repaired; + current = repaired; + } + + return current; + } + + private static List RepairPass(List lines) + { + var closeMarkerThenCommand = new System.Text.RegularExpressions.Regex( + "^(?.*?)(?(?:CONTENT|MSG|FIND|REPLACE|CMD|TO)>>>)((?[ \t]+@@.*))$"); + var endThenCommand = new System.Text.RegularExpressions.Regex( + "^(?@@end)(?[ \t]+@@.*)$"); + // A block opener fused with its payload opener ("@@newfile login.css <<[^<]*?))(?<<(lines.Count); + foreach (var line in lines) + { + var closeMatch = closeMarkerThenCommand.Match(line); + if (closeMatch.Success) + { + // Three lines: the content before the marker (if any), the close marker + // ALONE on its line (so the strict close check recognizes it), and the command. + if (closeMatch.Groups["before"].Value.Trim().Length > 0) + repaired.Add(closeMatch.Groups["before"].Value.TrimEnd()); + repaired.Add(closeMatch.Groups["marker"].Value); + repaired.Add(closeMatch.Groups["cmd"].Value.Trim()); + continue; + } + + var endMatch = endThenCommand.Match(line); + if (endMatch.Success) + { + repaired.Add(endMatch.Groups["marker"].Value); + repaired.Add(endMatch.Groups["cmd"].Value.Trim()); + continue; + } + + var openerMatch = blockOpenerThenPayload.Match(line); + if (openerMatch.Success) + { + repaired.Add(openerMatch.Groups[1].Value.Trim()); + repaired.Add(openerMatch.Groups["payload"].Value.Trim()); + continue; + } + + repaired.Add(line); + } + + return repaired; + } + /// /// Strips one surrounding Markdown code fence — only when the first non-blank line is an /// opening fence and the LAST non-blank line is a closing fence of the same or greater diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Parsing/AgentScriptValidator.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Parsing/AgentScriptValidator.cs index 99eae57..3e8fe78 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Parsing/AgentScriptValidator.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Parsing/AgentScriptValidator.cs @@ -31,16 +31,14 @@ public Outcome> Validate(AgentScriptParseResult par // These apply to parse-valid replies too, so they run before the early return. var hasDone = parse.Commands.Any(c => c is DoneCommand); var hasNext = parse.Commands.Any(c => c is NextCommand); - var hasSay = parse.Commands.Any(c => c is SayCommand); var hasOther = parse.Commands.Any(c => c is not (SayCommand or DoneCommand or NextCommand)); if (hasDone && hasNext) problems.Add(new FormatProblem(null, ErrorCodes.ParseConflictingControl, "Both @@next and @@done in one reply — use exactly one control directive.")); - if (hasSay && !hasDone && !hasNext && !hasOther) - problems.Add(new FormatProblem(null, ErrorCodes.ParseSayOnly, - "A reply with only @@say gives me nothing to do — add commands, @@next, or @@done.")); + // A say-only reply is a valid conversational turn: the loop handles it by + // re-waiting for the model, so it is no longer rejected here. if (parse.Problems.Count > 0) return Outcome.Successful((IReadOnlyList)problems); @@ -77,6 +75,10 @@ public Outcome> Validate(AgentScriptParseResult par CheckPath(moveFile.Path, command, problems); CheckPath(moveFile.ToPath, command, problems); break; + + case MkdirCommand mkdirCommand: + CheckPath(mkdirCommand.Path, command, problems); + break; } } diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Snapshot/SnapshotFilter.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Snapshot/SnapshotFilter.cs index 63385f0..7c93c5e 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Snapshot/SnapshotFilter.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Snapshot/SnapshotFilter.cs @@ -81,6 +81,133 @@ internal static List FilterTreeLines(IEnumerable lines, IReadOnl return result; } + /// + /// Drops dump sections (delimited by ### heading lines) whose FILE NAME matches + /// any wildcard pattern (case-insensitive *) — the generated/vendored denylist + /// (minified bundles, lock files, designer/migration snapshots). The heading itself is + /// kept out entirely, exactly like the node_modules directory exclusion. + /// + internal static string FilterDumpSectionsByFileName(string dump, IReadOnlyList filePatterns) + { + if (filePatterns.Count == 0) + return dump; + + var lines = dump.Replace("\r\n", "\n").Split('\n'); + var kept = new List(); + var skipping = false; + + foreach (var line in lines) + { + if (line.StartsWith("### ", StringComparison.Ordinal)) + { + var heading = line["### ".Length..].Trim(); + skipping = MatchesFileWildcard(LastHeadingSegment(heading), filePatterns); + } + + if (!skipping) + kept.Add(line); + } + + return string.Join("\n", kept).Trim('\n'); + } + + /// + /// Caps every section's fenced code content at : oversized + /// files keep their ### heading (existence stays visible in the attachment) but the + /// content is replaced by a placeholder pointing at the file-read tool. + /// + internal static string CapDumpSectionSizes(string dump, int maxFileChars) + { + if (maxFileChars <= 0) + return dump; + + var lines = dump.Replace("\r\n", "\n").Split('\n'); + var result = new List(); + var i = 0; + while (i < lines.Length) + { + if (!lines[i].StartsWith("### ", StringComparison.Ordinal)) + { + result.Add(lines[i]); + i++; + continue; + } + + // A section: heading, blank, fence, content…, fence (blank follows, if any). + var sectionStart = i; + var fenceCount = 0; + var contentStart = -1; + var contentEnd = -1; + var j = i + 1; + for (; j < lines.Length; j++) + { + if (lines[j].StartsWith("```", StringComparison.Ordinal) || + lines[j].StartsWith("````", StringComparison.Ordinal)) + { + fenceCount++; + if (fenceCount == 1) + contentStart = j + 1; + else + { + contentEnd = j; + break; + } + } + } + + if (fenceCount < 2 || contentStart < 0 || contentEnd < 0) + { + // Malformed section: keep verbatim to the next heading (or end). + while (i <= sectionStart && i < lines.Length) + result.Add(lines[i++]); + continue; + } + + var contentLength = 0; + for (var k = contentStart; k < contentEnd; k++) + contentLength += lines[k].Length + 1; + + if (contentLength > maxFileChars) + { + // Oversized: heading + fences remain, the content becomes the placeholder. + var sizeKb = Math.Max(1, contentLength / 1024); + result.Add(lines[i]); // heading + for (var k = i + 1; k < contentStart - 1; k++) + result.Add(lines[k]); + result.Add(lines[contentStart - 1]); // opening fence + result.Add($"[file too large to attach: {sizeKb} KB - use the file-read tool if its content is needed]"); + result.Add(lines[contentEnd]); // closing fence + } + else + { + for (var k = i; k <= contentEnd; k++) + result.Add(lines[k]); + } + + i = contentEnd + 1; + } + + return string.Join("\n", result).Trim('\n'); + } + + /// Case-insensitive * wildcard match against a file name. + internal static bool MatchesFileWildcard(string fileName, IReadOnlyList patterns) + { + foreach (var pattern in patterns) + { + if (pattern.Length == 0) + continue; + + var regex = "^" + Regex + .Escape(pattern.Trim()) + .Replace("\\*", ".*", StringComparison.Ordinal) + "$"; + if (Regex.IsMatch(fileName, regex, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) + return true; + } + + return false; + } + /// /// Drops dump sections (delimited by ### heading lines) whose heading's file name /// matches any exclude pattern (previous outputs, generated reports). diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Snapshot/SnapshotService.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Snapshot/SnapshotService.cs index 9f4e697..db1d138 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Snapshot/SnapshotService.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Snapshot/SnapshotService.cs @@ -67,8 +67,15 @@ public async Task RefreshAsync(string workspaceRoot) } } - // The baseline powers TreeAndChanged mode; a failed capture only means the next - // BuildAttachments falls back to the full dump, so it must never fail the refresh. + // The change STASH is what TreeAndChanged attaches: it must be computed BEFORE the + // baseline is overwritten, comparing the CURRENT state against the PREVIOUS refresh's + // baseline - that is the only diff that can ever contain the agent's real changes + // (the refresh runs right before the attachments are built). A failed stash only + // means the next BuildAttachments falls back to the budget-limited dump. + await StashChangesAsync(workspaceRoot); + + // The baseline powers the NEXT stash; a failed capture only means the next stash is + // empty, so it must never fail the refresh. await UpdateSnapshotBaselineAsync(workspaceRoot); return Outcome.Successful(); @@ -124,7 +131,9 @@ private async Task UpdateSnapshotBaselineAsync(string workspaceRoot) } /// - public async Task>> BuildAttachmentsAsync(string workspaceRoot, KeylessOptions options) + public async Task>> BuildAttachmentsAsync( + string workspaceRoot, KeylessOptions options, + string? requestText = null, Func? logEvent = null) { var treePath = Path.Combine(workspaceRoot, TreeResultName); var dumpPath = Path.Combine(workspaceRoot, CodeDumpResultName); @@ -157,47 +166,169 @@ public async Task>> BuildAttachmentsAs if (filesDropped + valuesMasked > 0) tree = $"[{filesDropped} files filtered and {valuesMasked} values masked as sensitive]\n" + tree; + // Per-file cap + generated/vendored denylist (attachment copy only). + sanitizedDump = SnapshotFilter.CapDumpSectionSizes(sanitizedDump, options.Snapshot.MaxDumpedFileChars); + sanitizedDump = SnapshotFilter.FilterDumpSectionsByFileName(sanitizedDump, filter.ExcludeFilePatterns); + var attachments = new List { new(TreeResultName, SnapshotAttachmentKind.TreeView, tree), }; // TreeAndChanged: the tree plus what changed since the last refresh — the full dump is - // replaced; anything unchanged is one readfile away. Without a usable baseline (first - // run, unreadable file, failed diff) the full dump keeps the model operational. + // replaced; anything unchanged is one readfile away. WITHOUT a usable baseline (first + // run, unreadable file, failed diff) the attachment falls back to the same + // BUDGET-LIMITED dump as Full mode (never an unlimited full dump) — and because + // RefreshAsync captures the baseline on every refresh regardless of mode, the very + // next turn diffs normally. + string? changedFiles = null; if (options.Snapshot.Mode == SnapshotMode.TreeAndChanged) + changedFiles = await BuildChangedFilesContentAsync(workspaceRoot); + + if (changedFiles is not null) { - var changedFiles = await BuildChangedFilesContentAsync(workspaceRoot); - if (changedFiles is not null) - { - attachments.Add(new SnapshotAttachment("changed-files.txt", SnapshotAttachmentKind.ChangedFiles, changedFiles)); - return Outcome.Successful((IReadOnlyList)attachments); - } + attachments.Add(new SnapshotAttachment("changed-files.txt", SnapshotAttachmentKind.ChangedFiles, changedFiles)); + return Outcome.Successful((IReadOnlyList)attachments); } - var parts = SnapshotFilter.ChunkDump(sanitizedDump, MaxAttachmentChars); + // --- Total-size budget across EVERYTHING attached to this one turn --- + var total = tree.Length + sanitizedDump.Length; + if (total <= options.Snapshot.MaxTotalAttachmentChars) + { + AttachDumpParts(attachments, sanitizedDump); + return Outcome.Successful((IReadOnlyList)attachments); + } + + var reducedDump = BuildBudgetLimitedDump(sanitizedDump, requestText, out var omittedFiles, out var omittedChars); + if (logEvent is not null) + await logEvent("snapshot-budget-exceeded", new + { + OriginalTotalChars = total, + Ceiling = options.Snapshot.MaxTotalAttachmentChars, + OmittedFiles = omittedFiles, + OmittedChars = omittedChars, + }); + + AttachDumpParts(attachments, reducedDump); + return Outcome.Successful((IReadOnlyList)attachments); + } + + private void AttachDumpParts(List attachments, string dump) + { + var parts = SnapshotFilter.ChunkDump(dump, MaxAttachmentChars); for (var i = 0; i < parts.Count; i++) { var name = parts.Count == 1 ? CodeDumpResultName : $"code-dump-result-part{i + 1}.md"; attachments.Add(new SnapshotAttachment(name, SnapshotAttachmentKind.CodeDump, parts[i])); } + } - return Outcome.Successful((IReadOnlyList)attachments); + /// + /// The size-budget dump: files REFERENCED by the turn's request text (file name or path, + /// case-insensitive) keep their full content; everything else becomes one omission note + /// PER FOLDER ("[omitted: 3 files under src/Core/ (182 KB total) … use the file-read + /// tool …]"). The tree stays attached regardless — it is cheap and essential for + /// navigation, which is why the dump alone is reduced here. + /// + internal static string BuildBudgetLimitedDump( + string dump, string? requestText, out int omittedFiles, out long omittedChars) + { + omittedFiles = 0; + omittedChars = 0; + + var sections = new List<(string Heading, string FileName, string Folder, string Section, bool Referenced)>(); + foreach (var section in SplitSections(dump)) + { + var headingLine = section.Split('\n').FirstOrDefault(l => l.StartsWith("### ", StringComparison.Ordinal)); + var heading = headingLine is null ? string.Empty : headingLine["### ".Length..].Trim(); + var fileName = HeadingFileName(heading); + var folderSegments = heading.Split(" > ", StringSplitOptions.RemoveEmptyEntries); + var folder = folderSegments.Length > 1 + ? string.Join('/', folderSegments.Take(folderSegments.Length - 1)) + : "(workspace root)"; + + var referenced = requestText is not null && IsReferencedByRequest(requestText, heading, fileName); + sections.Add((heading, fileName, folder, section, referenced)); + } + + var kept = new System.Text.StringBuilder(); + var omittedByFolder = new List<(string Folder, int Count, long Chars)>(); + foreach (var group in sections.GroupBy(s => s.Folder)) + { + foreach (var entry in group.Where(s => s.Referenced)) + kept.Append(entry.Section); + + var omitted = group.Where(s => !s.Referenced).ToList(); + if (omitted.Count == 0) + continue; + + omittedFiles += omitted.Count; + omittedChars += omitted.Sum(o => (long)o.Section.Length); + omittedByFolder.Add((group.Key, omitted.Count, omitted.Sum(o => (long)o.Section.Length))); + } + + foreach (var (folder, count, chars) in omittedByFolder.OrderBy(o => o.Folder, StringComparer.OrdinalIgnoreCase)) + { + var sizeKb = Math.Max(1, (int)(chars / 1024)); + kept.AppendLine(count == 1 + ? $"[omitted: 1 file under {folder}/ ({sizeKb} KB) - over the attachment size budget; use the file-read tool if its content is needed]" + : $"[omitted: {count} files under {folder}/ ({sizeKb} KB total) - over the attachment size budget; use the file-read tool if their content is needed]"); + } + + return kept.ToString().Trim('\n'); } + private static bool IsReferencedByRequest(string requestText, string heading, string fileName) + { + if (requestText.Contains(fileName, StringComparison.OrdinalIgnoreCase)) + return true; + + var slashPath = heading.Replace(" > ", "/"); + if (requestText.Contains(slashPath, StringComparison.OrdinalIgnoreCase)) + return true; + + return requestText.Contains(heading.Replace(" > ", "\\"), StringComparison.OrdinalIgnoreCase); + } + + private static IEnumerable SplitSections(string dump) + { + var lines = dump.Replace("\r\n", "\n").Split('\n'); + var current = new System.Text.StringBuilder(); + + foreach (var line in lines) + { + if (line.StartsWith("### ", StringComparison.Ordinal) && current.Length > 0) + { + yield return current.ToString(); + current.Clear(); + } + + current.Append(line).Append('\n'); + } + + if (current.Length > 0) + yield return current.ToString(); + } + + private static readonly string StashRelativePath = Path.Combine(".agent", "snapshot-changes.json"); + + private sealed record ChangeStash(List Lines); + /// - /// Diffs the workspace against the persisted baseline and renders " " lines; - /// null when there is no usable baseline or the diff failed (fall back to the full dump). + /// Diffs the CURRENT workspace against the PREVIOUS refresh's baseline and stashes the + /// " " lines under .agent/snapshot-changes.json. Runs inside RefreshAsync + /// BEFORE the baseline is overwritten - afterwards the diff would always be vacuously + /// empty, which is why the old TreeAndChanged never showed real changes. Best-effort. /// - private async Task BuildChangedFilesContentAsync(string workspaceRoot) + private async Task StashChangesAsync(string workspaceRoot) { var baselinePath = Path.Combine(workspaceRoot, BaselineRelativePath); if (!_storage.FileExists(baselinePath)) - return null; + return; // no previous baseline: the next BuildAttachments takes the budget path var read = await _storage.ReadAllTextAsync(baselinePath); if (read.IsFailure) - return null; + return; BaselineFile? baseline; try @@ -206,11 +337,11 @@ public async Task>> BuildAttachmentsAs } catch { - return null; + return; } if (baseline is null) - return null; + return; object handle = baseline.GitAvailable ? (object?)baseline.Porcelain ?? new List() @@ -221,10 +352,46 @@ public async Task>> BuildAttachmentsAs var diff = await _changeTracker.DiffAsync(workspaceRoot, handle, baseline.GitAvailable); if (diff.IsFailure || diff.Data is null) + return; + + var stash = new ChangeStash(diff.Data.Select(c => $"{c.Kind} {c.Path}").ToList()); + var stashPath = Path.Combine(workspaceRoot, StashRelativePath); + var mkdir = await _storage.CreateDirectoryAsync(Path.GetDirectoryName(stashPath)!); + if (mkdir.IsFailure) + return; + await _storage.WriteAllTextAsync(stashPath, System.Text.Json.JsonSerializer.Serialize(stash)); + } + + /// + /// The stashed change lines from the last refresh; null when there is no stash (first + /// run), it is unreadable, or it is EMPTY (nothing to show from diffs - the + /// budget-limited dump takes over instead, which is strictly more useful than + /// "(no changes since the last snapshot.)"). + /// + private async Task BuildChangedFilesContentAsync(string workspaceRoot) + { + var stashPath = Path.Combine(workspaceRoot, StashRelativePath); + if (!_storage.FileExists(stashPath)) + return null; + + var read = await _storage.ReadAllTextAsync(stashPath); + if (read.IsFailure) + return null; + + ChangeStash? stash; + try + { + stash = System.Text.Json.JsonSerializer.Deserialize(read.Data); + } + catch + { + return null; + } + + if (stash is null || stash.Lines is not { Count: > 0 }) return null; - var lines = diff.Data.Select(c => $"{c.Kind} {c.Path}").ToList(); - return (lines.Count > 0 ? string.Join("\n", lines) : "(no changes since the last snapshot.)") + "\n"; + return string.Join("\n", stash.Lines) + "\n"; } private sealed record BaselineFile( diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Workspace/EnvironmentProbe.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Workspace/EnvironmentProbe.cs index 77dded1..b61764e 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Workspace/EnvironmentProbe.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Workspace/EnvironmentProbe.cs @@ -1,3 +1,4 @@ +using ImanSoftware.FileStorage; using ImanSoftware.KeylessAgent.Core.Implementation.Execution; using ImanSoftware.Outcomes; @@ -5,29 +6,129 @@ namespace ImanSoftware.KeylessAgent.Core.Implementation.Workspace; /// /// Probes the environment once at session start (in parallel, 3s timeout each) and produces -/// a compact block for the first chat message plus a one-line summary for reminders. +/// a compact block for the first chat message plus a one-line summary for reminders. The +/// per-tool states are also available structurally (for the fail-fast rule and the UIs) and +/// are cached in .agent/env.json for 24 h — with a per-tool 1 h freshness window and explicit +/// invalidation when reality disagrees with the cache. /// -internal sealed class EnvironmentProbe +internal sealed class EnvironmentProbe : IEnvironmentTools { - private readonly ProcessRunner _runner; + private readonly IProcessRunner _runner; public EnvironmentProbe() : this(new ProcessRunner()) { } - internal EnvironmentProbe(ProcessRunner runner) + internal EnvironmentProbe(IProcessRunner runner) { _runner = runner; } + private sealed record EnvCache(string CreatedAtUtc, string Block, string Summary, Dictionary? Tools); + /// - /// Probes OS, shell and tool versions in parallel (3s timeout each). + /// Probes the environment unless a fresh (<24h) cache exists in .agent/env.json; + /// forceRefresh (--refresh-env) bypasses the cache. Fresh results rewrite the cache. + /// + public async Task Tools)>> ProbeCachedAsync( + string workspaceRoot, bool forceRefresh, CancellationToken cancellationToken = default) + { + var storage = ImanFileStorageFactory.CreateStorage(); + var cachePath = Path.Combine(workspaceRoot, ".agent", "env.json"); + + if (!forceRefresh && storage.FileExists(cachePath)) + { + var read = await storage.ReadAllTextAsync(cachePath); + if (read.IsSuccess) + { + try + { + var cached = System.Text.Json.JsonSerializer.Deserialize(read.Data); + if (cached is not null && + !string.IsNullOrWhiteSpace(cached.Block) && + cached.Tools is { Count: > 0 } && + DateTimeOffset.TryParse(cached.CreatedAtUtc, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal, out var at) && + DateTimeOffset.UtcNow - at < TimeSpan.FromHours(24)) + { + return Outcome.Successful((cached.Block, cached.Summary ?? string.Empty, + (IReadOnlyDictionary)cached.Tools)); + } + } + catch + { + // A corrupt cache is as good as no cache. + } + } + } + + var probe = await ProbeAsync(workspaceRoot, cancellationToken); + if (probe.IsFailure) + return probe; + + try + { + var payload = System.Text.Json.JsonSerializer.Serialize(new EnvCache( + DateTimeOffset.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + probe.Data.Block, + probe.Data.Summary, + new Dictionary(probe.Data.Tools))); + var mkdir = await storage.CreateDirectoryAsync(Path.GetDirectoryName(cachePath)!); + if (mkdir.IsSuccess) + await storage.WriteAllTextAsync(cachePath, payload); + } + catch + { + // Cache writes are best-effort; a missed cache only costs a re-probe. + } + + return probe; + } + + /// + public async Task> GetToolsCachedAsync( + string workspaceRoot, bool forceRefresh, CancellationToken cancellationToken = default) + { + var probe = await ProbeCachedAsync(workspaceRoot, forceRefresh, cancellationToken); + return probe.IsSuccess ? probe.Data.Tools : new Dictionary(); + } + + /// + public async Task GetToolStatusAsync(string workspaceRoot, string tool, CancellationToken cancellationToken = default) + { + var cache = await ReadCacheAsync(workspaceRoot); + if (cache?.Tools is not null && + cache.Tools.TryGetValue(tool, out var cached) && + DateTimeOffset.TryParse(cached.CheckedAtUtc, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal, out var checkedAt) && + DateTimeOffset.UtcNow - checkedAt < TimeSpan.FromHours(1)) + { + return cached; // fresh enough (1 h) — no probe needed for the fail-fast rule + } + + // The cache is older than 1 h (or does not know the tool): cheap single-tool re-probe. + var refreshed = await ProbeToolAsync(workspaceRoot, tool, cancellationToken); + await WriteCachedToolAsync(workspaceRoot, cache, refreshed, cancellationToken); + return refreshed; + } + + /// + public async Task InvalidateToolAsync(string workspaceRoot, string tool, CancellationToken cancellationToken = default) + { + var refreshed = await ProbeToolAsync(workspaceRoot, tool, cancellationToken); + await WriteCachedToolAsync(workspaceRoot, await ReadCacheAsync(workspaceRoot), refreshed, cancellationToken); + } + + /// + /// Probes OS, shell and tool versions in parallel (3s timeout each): git, dotnet, node, + /// npm, and python resolved across python / py -3 / python3 with WindowsApps-store-alias + /// detection. The block and the summary both carry explicit Available/Missing lists. /// /// The workspace (working directory for version probes). /// Cancellation token. - /// A multi-line environment block and a one-line summary. - public async Task> ProbeAsync( + /// The environment block, the one-line summary and the per-tool states. + public async Task Tools)>> ProbeAsync( string workspaceRoot, CancellationToken cancellationToken = default) { var osTask = RunAsync("cmd", "/c ver", workspaceRoot, cancellationToken); @@ -36,37 +137,207 @@ internal EnvironmentProbe(ProcessRunner runner) var nodeTask = RunAsync("node", "-v", workspaceRoot, cancellationToken); var npmTask = RunAsync("npm", "-v", workspaceRoot, cancellationToken); var pythonTask = RunAsync("python", "--version", workspaceRoot, cancellationToken); + var pyTask = RunAsync("py", "-3 --version", workspaceRoot, cancellationToken); + var python3Task = RunAsync("python3", "--version", workspaceRoot, cancellationToken); + var wherePythonTask = RunAsync("cmd", "/c where python", workspaceRoot, cancellationToken); string?[] results; try { - results = await Task.WhenAll(osTask, gitTask, dotnetTask, nodeTask, npmTask, pythonTask); + results = await Task.WhenAll(osTask, gitTask, dotnetTask, nodeTask, npmTask, pythonTask, pyTask, python3Task, wherePythonTask); } catch { // Per-probe failures already return null; this fires only for framework errors. - results = [await osTask, await gitTask, await dotnetTask, await nodeTask, await npmTask, await pythonTask]; + results = [await osTask, await gitTask, await dotnetTask, await nodeTask, await npmTask, await pythonTask, await pyTask, await python3Task, await wherePythonTask]; } - string? os = results[0], gitV = results[1], dotnetV = results[2], nodeV = results[3], npmV = results[4], pythonV = results[5]; + string? os = results[0], gitV = results[1], dotnetV = results[2], nodeV = results[3], npmV = results[4], + pythonV = results[5], pyV = results[6], python3V = results[7], wherePython = results[8]; - string FirstLine(string? text) => - string.IsNullOrWhiteSpace(text) ? "not installed" : text.Trim().Split('\n')[0].Trim(); + var tools = BuildToolStates(gitV, dotnetV, nodeV, npmV, pythonV, pyV, python3V, wherePython); + var block = BuildBlock(os, tools, workspaceRoot); + var summary = BuildSummary(tools); + return Outcome.Successful((block, summary, (IReadOnlyDictionary)tools)); + } + + private static Dictionary BuildToolStates( + string? gitV, string? dotnetV, string? nodeV, string? npmV, + string? pythonV, string? pyV, string? python3V, string? wherePython) + { + var now = DateTimeOffset.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture); + var tools = new Dictionary(StringComparer.OrdinalIgnoreCase); - var block = string.Join("\n", + void Add(string tool, string? version, string command) + { + tools[tool] = new ToolStatus( + tool, + string.IsNullOrWhiteSpace(version) ? ToolStates.Missing : ToolStates.Available, + version?.Trim().Split('\n')[0].Trim() ?? string.Empty, + command, + now); + } + + Add("git", gitV, "git"); + Add("dotnet", dotnetV, "dotnet"); + Add("node", nodeV, "node"); + Add("npm", npmV, "npm"); + Add("py", pyV, "py -3"); + Add("python3", python3V, "python3"); + + // python resolves across python / py -3 / python3 — the first that wins is reported + // WITH the command to use. A WindowsApps-only `where python` is a Store alias, never + // a real install. + var storeAliasOnly = pythonV is null && + wherePython is not null && + wherePython.Split('\n').Any(line => line.Trim().Length > 0 && line.Contains("WindowsApps", StringComparison.OrdinalIgnoreCase)); + + if (pythonV is not null) + tools["python"] = new ToolStatus("python", ToolStates.Available, FirstLine(pythonV), "python", now); + else if (pyV is not null) + tools["python"] = new ToolStatus("python", ToolStates.Available, FirstLine(pyV), "py -3", now); + else if (python3V is not null) + tools["python"] = new ToolStatus("python", ToolStates.Available, FirstLine(python3V), "python3", now); + else if (storeAliasOnly) + tools["python"] = new ToolStatus("python", ToolStates.StoreAlias, "WindowsApps stub", string.Empty, now); + else + tools["python"] = new ToolStatus("python", ToolStates.Missing, string.Empty, string.Empty, now); + + return tools; + } + + private static string BuildBlock(string? os, Dictionary tools, string workspaceRoot) + { + string Line(string tool) + { + var status = tools[tool]; + return status.State switch + { + ToolStates.Available when status.Command != tool => $"{tool}: {status.Version} (use: {status.Command})", + ToolStates.Available => $"{tool}: {status.Version}", + ToolStates.StoreAlias => $"{tool}: Store alias only (not a real install)", + _ => $"{tool}: not installed", + }; + } + + return string.Join("\n", "[ENVIRONMENT]", $"OS: {FirstLine(os)}", "Shell: cmd.exe", - $"git: {FirstLine(gitV)}", - $"dotnet: {FirstLine(dotnetV)}", - $"node: {FirstLine(nodeV)}", - $"npm: {FirstLine(npmV)}", - $"python: {FirstLine(pythonV)}", + Line("git"), + Line("dotnet"), + Line("node"), + Line("npm"), + Line("python"), + $"Available: {AvailableList(tools)}", + $"Missing: {MissingList(tools)}", $"Workspace: {workspaceRoot}", "[/ENVIRONMENT]"); + } + + private static string BuildSummary(Dictionary tools) + => $"Available: {AvailableList(tools)} | Missing: {MissingList(tools)}"; + + private static string AvailableList(Dictionary tools) + { + var entries = tools.Values + .Where(tool => tool.State == ToolStates.Available) + .Select(tool => tool.Command != tool.Tool + ? $"{tool.Tool} ({tool.Version}, use: {tool.Command})" + : $"{tool.Tool} ({tool.Version})") + .ToList(); + return entries.Count == 0 ? "none" : string.Join(", ", entries); + } + + private static string MissingList(Dictionary tools) + { + var entries = tools.Values + .Where(tool => tool.State is ToolStates.Missing or ToolStates.StoreAlias) + .Select(tool => tool.State == ToolStates.StoreAlias ? $"{tool.Tool} (Store alias only)" : tool.Tool) + .ToList(); + return entries.Count == 0 ? "none" : string.Join(", ", entries); + } + + /// Probes ONE tool (python includes its py -3/python3 fallbacks and the store check). + private async Task ProbeToolAsync(string workspaceRoot, string tool, CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture); + if (tool is not ("python" or "py" or "python3")) + { + var (program, arguments) = VersionCommandOf(tool); + var version = await RunAsync(program, arguments, workspaceRoot, cancellationToken); + return new ToolStatus(tool, + string.IsNullOrWhiteSpace(version) ? ToolStates.Missing : ToolStates.Available, + version?.Trim().Split('\n')[0].Trim() ?? string.Empty, + tool, now); + } + + // The python family: the first working command wins; the Store alias is detected. + var pythonV = await RunAsync("python", "--version", workspaceRoot, cancellationToken); + var pyV = await RunAsync("py", "-3 --version", workspaceRoot, cancellationToken); + var python3V = await RunAsync("python3", "--version", workspaceRoot, cancellationToken); + var wherePython = await RunAsync("cmd", "/c where python", workspaceRoot, cancellationToken); + var states = BuildToolStates(null, null, null, null, pythonV, pyV, python3V, wherePython); + + return tool switch + { + "py" => states["py"], + "python3" => states["python3"], + _ => states["python"], + }; + } + + private static (string Program, string Arguments) VersionCommandOf(string tool) => tool switch + { + "py" => ("py", "-3 --version"), + _ => (tool, "--version"), + }; + + private async Task ReadCacheAsync(string workspaceRoot) + { + var storage = ImanFileStorageFactory.CreateStorage(); + var cachePath = Path.Combine(workspaceRoot, ".agent", "env.json"); + if (!storage.FileExists(cachePath)) + return null; + + var read = await storage.ReadAllTextAsync(cachePath); + if (!read.IsSuccess) + return null; - var summary = $"git={Short(gitV)} dotnet={Short(dotnetV)} node={Short(nodeV)} python={Short(pythonV)}"; - return Outcome.Successful((block, summary)); + try + { + return System.Text.Json.JsonSerializer.Deserialize(read.Data); + } + catch + { + return null; + } + } + + private async Task WriteCachedToolAsync(string workspaceRoot, EnvCache? cache, ToolStatus refreshed, CancellationToken cancellationToken) + { + try + { + var storage = ImanFileStorageFactory.CreateStorage(); + var cachePath = Path.Combine(workspaceRoot, ".agent", "env.json"); + var tools = cache?.Tools is { Count: > 0 } + ? new Dictionary(cache.Tools, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + tools[refreshed.Tool] = refreshed; + + var payload = System.Text.Json.JsonSerializer.Serialize(new EnvCache( + cache?.CreatedAtUtc ?? DateTimeOffset.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + cache?.Block ?? string.Empty, + cache?.Summary ?? string.Empty, + tools)); + var mkdir = await storage.CreateDirectoryAsync(Path.GetDirectoryName(cachePath)!); + if (mkdir.IsSuccess) + await storage.WriteAllTextAsync(cachePath, payload); + } + catch + { + // Cache writes are best-effort. + } } private async Task RunAsync(string program, string arguments, string workingDirectory, CancellationToken cancellationToken) @@ -87,6 +358,6 @@ string FirstLine(string? text) => } } - private static string Short(string? text) => - string.IsNullOrWhiteSpace(text) ? "none" : text.Trim().Split('\n')[0].Trim(); + private static string FirstLine(string? text) => + string.IsNullOrWhiteSpace(text) ? "not installed" : text.Trim().Split('\n')[0].Trim(); } diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Workspace/IEnvironmentTools.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Workspace/IEnvironmentTools.cs new file mode 100644 index 0000000..d49b8cd --- /dev/null +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Workspace/IEnvironmentTools.cs @@ -0,0 +1,77 @@ +namespace ImanSoftware.KeylessAgent.Core.Implementation.Workspace; + +/// The well-known states of a probed tool. +public static class ToolStates +{ + /// The tool answered its version probe. + public const string Available = "available"; + + /// The command resolves only to a WindowsApps Store stub (not a real install). + public const string StoreAlias = "store-alias"; + + /// The tool did not answer its version probe. + public const string Missing = "missing"; +} + +/// The probed state of one tool. +/// Canonical tool name (git, dotnet, node, npm, python, py, python3). +/// Available, store-alias or missing (see ). +/// First line of the version output (empty when missing). +/// The command that works for this tool ("py -3" when the python launcher won). +/// When this status was probed (drives the per-tool 1 h freshness rule). +public sealed record ToolStatus(string Tool, string State, string Version, string Command, string CheckedAtUtc); + +/// +/// Structured access to the probed environment tools: the command executor uses it to fail +/// fast on missing tools, the UIs use it for the user-facing "Tools:" line. Implementations +/// cache (24 h) and support per-tool re-probes (1 h freshness) plus explicit invalidation. +/// +public interface IEnvironmentTools +{ + /// Returns every tool status (cached unless ). + Task> GetToolsCachedAsync( + string workspaceRoot, bool forceRefresh, CancellationToken cancellationToken = default); + + /// + /// The status of ONE tool: served from the cache when it was checked within the last hour, + /// otherwise cheaply re-probed (a single version command) and written back. + /// + Task GetToolStatusAsync(string workspaceRoot, string tool, CancellationToken cancellationToken = default); + + /// + /// Re-probes one tool immediately and writes the result into the cache — used when a + /// command's program turns out to be missing although the cache said it is installed + /// (or the reverse). + /// + Task InvalidateToolAsync(string workspaceRoot, string tool, CancellationToken cancellationToken = default); + + /// + /// The one-line per-session status for the UIs: available tools as OK, missing tools as + /// MISSING with the official download page (git-scm.com, dotnet.microsoft.com, + /// python.org, nodejs.org). + /// + static string FormatToolsLine(IReadOnlyDictionary tools) + { + static string LinkOf(string tool) => tool switch + { + "git" => "https://git-scm.com/downloads", + "dotnet" => "https://dotnet.microsoft.com/download", + "python" or "py" or "python3" => "https://www.python.org/downloads/", + _ => "https://nodejs.org", + }; + + static string Render(ToolStatus tool) => tool.State switch + { + ToolStates.Available => $"{tool.Tool} OK", + ToolStates.StoreAlias => $"{tool.Tool} STORE-ALIAS ONLY ({LinkOf(tool.Tool)})", + _ => $"{tool.Tool} MISSING ({LinkOf(tool.Tool)})", + }; + + var order = new[] { "git", "dotnet", "python", "py", "python3", "node", "npm" }; + var parts = tools.Values + .OrderBy(tool => Array.IndexOf(order, tool.Tool) is var at && at >= 0 ? at : int.MaxValue) + .Select(Render) + .ToList(); + return $"Tools: {(parts.Count == 0 ? "unknown" : string.Join(", ", parts))}"; + } +} diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Workspace/TimingRecorder.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Workspace/TimingRecorder.cs index 4552d33..b7cf3a7 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Workspace/TimingRecorder.cs +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Workspace/TimingRecorder.cs @@ -7,10 +7,14 @@ namespace ImanSoftware.KeylessAgent.Core.Implementation.Workspace; /// /// Simple thread-safe per-phase timing recorder. Start(phase) opens a Stopwatch, -/// Stop(phase) records the elapsed time. +/// Stop(phase) records the elapsed time. Re-entrant Start is ignored. +/// User-wait time (approvals, menus, @@ask) is recorded as its own phase so the +/// summary can show the turn both as wall clock and without user waits. /// internal sealed class TimingRecorder : ITimingRecorder { + public const string UserWaitPhase = "user-wait"; + private readonly ConcurrentDictionary _running = new(StringComparer.Ordinal); private readonly ConcurrentQueue<(string Phase, TimeSpan Duration)> _entries = new(); @@ -53,22 +57,58 @@ public void Stop(string phase) ? TimeSpan.Zero : _lastStop - _firstStart; + /// + /// Wall-clock total minus the accumulated user-wait time — the time the agent + /// actually spent working. + /// + public TimeSpan TotalWithoutUserWait + { + get + { + var userWait = TimeSpan.FromTicks(_entries.Where(e => e.Phase == UserWaitPhase).Sum(e => e.Duration.Ticks)); + return Total - userWait < TimeSpan.Zero ? TimeSpan.Zero : Total - userWait; + } + } + /// public string FormatTable() { if (_entries.IsEmpty) return "No timings recorded."; + // Duplicate phase names are numbered (send-confirm #1, #2 …) so concurrent or + // repeated phases stay readable and sortable. + // Pre-count so a phase that appears more than once gets ALL its rows numbered + // (#1..#N); single-occurrence phases keep their plain name. + var counts = _entries.GroupBy(e => e.Phase).ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal); + var seen = new Dictionary(StringComparer.Ordinal); + var rows = _entries.Select(entry => + { + var total = counts[entry.Phase]; + string display; + if (total == 1) + { + display = entry.Phase; + } + else + { + var n = seen.GetValueOrDefault(entry.Phase) + 1; + seen[entry.Phase] = n; + display = $"{entry.Phase} #{n}"; + } + + return (display, entry.Duration); + }).ToList(); + var builder = new System.Text.StringBuilder(); builder.AppendLine("Timing Summary"); builder.AppendLine("─────────────────────────────────────────"); - foreach (var (phase, duration) in _entries) - { - builder.AppendLine($" {phase,-30} {duration.TotalMilliseconds,8:F0} ms"); - } + foreach (var (display, duration) in rows) + builder.AppendLine($" {display,-30} {duration.TotalMilliseconds,8:F0} ms"); builder.AppendLine("─────────────────────────────────────────"); - builder.AppendLine($" {"TOTAL",-30} {Total.TotalMilliseconds,8:F0} ms"); + builder.AppendLine($" {"TOTAL (wall clock)",-30} {Total.TotalMilliseconds,8:F0} ms"); + builder.AppendLine($" {"TOTAL without user-wait",-30} {TotalWithoutUserWait.TotalMilliseconds,8:F0} ms"); return builder.ToString(); } } diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Resources/format-correction.txt b/src/Core/ImanSoftware.KeylessAgent.Core/Resources/format-correction.txt index d2a1250..d55bfb9 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Resources/format-correction.txt +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Resources/format-correction.txt @@ -43,4 +43,27 @@ MSG>>> Valid keywords ONLY: @@say, @@newfile, @@updatefile, @@editfile, @@movefile, @@deletefile, @@readfile, @@runcommand, @@ask, @@commit, @@next, @@done — plus the final line @@eof. There is no @@command, no JSON, no markdown fences around the reply. Every block starts with @@keyword, may contain <<>> payload sections, and ends with @@end. Also remember: payload markers are ALONE on their own line (never "<<>>" on one line); paths never start with "./"; use @@next to get this batch's results before continuing and @@done to end the turn (never both); and YOU execute commands yourself via @@runcommand — never give the user commands to run. +HARD RULE — EVERY payload marker (<<>>) must be the ENTIRE line. "CONTENT>>> @@end @@newfile x" on ONE line is a FORMAT ERROR and the whole reply is REJECTED. +EXAMPLE OF A VALID RESPONSE (copy this shape exactly): + +@@say +<<>> +@@end +@@newfile src/login.html +<< +``` +CONTENT>>> +@@end +@@done +<<>> +@@end +@@eof + +Every marker must be ALONE on its own line, and file content must be inside a backtick fence. Resend your COMPLETE response using only AgentScript v1. Do not apologize or explain. Do not add text outside blocks. diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Resources/selectors.json b/src/Core/ImanSoftware.KeylessAgent.Core/Resources/selectors.json index 03981e7..313eb02 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Resources/selectors.json +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Resources/selectors.json @@ -1,4 +1,9 @@ { + // Bump whenever the embedded default selectors change: older workspace files are + // merged (never-customized keys refreshed, user customizations preserved) and backed + // up as selectors.json.v.bak. See SelectorsCatalog.ShippedValues. + "CatalogVersion": 2, + "InputBox": [ "textarea[placeholder=\"Message DeepSeek\"]", "textarea#chat-input", @@ -32,14 +37,16 @@ ".ds-think-content" ], "MessageCopyButton": [ - ".ds-message [role=\"button\"][aria-label=\"Copy\"]", + "[role=\"button\"][aria-label=\"copy\" i]", + "[role=\"button\"][title=\"copy\" i]", + ".ds-message [role=\"button\"][aria-label*=\"copy\" i]", ".ds-message .ds-flex [role=\"button\"]:first-child", ".ds-message [role=\"button\"]:has-text(\"Copy\")" ], "StopIndicator": [ + "div:has(textarea) [role=\"button\"][aria-label=\"Stop\"]", "[role=\"button\"][aria-label=\"Stop\"]", - "[role=\"button\"]:has-text(\"Stop\")", - ".ds-button--filled:has-text(\"Stop\")" + ".ds-button--filled[aria-label=\"Stop\"]" ], "FileInput": [ "input[type=\"file\"][multiple]", @@ -50,6 +57,24 @@ "[class*='chips']", "[data-testid='file-chip']" ], + "AttachmentRemove": [ + "[class*='file-chip'] button", + "[class*='file-chip'] [role=\"button\"]", + "[class*='chip'] [aria-label*='remove' i]", + "[class*='chip'] [aria-label*='delete' i]", + "[class*='chip'] [aria-label*='close' i]", + "[class*='chip'] [aria-label*='cancel' i]", + "[class*='chip'] [class*='close']", + "[class*='chip'] svg[class*='close']" + ], + "AttachmentFailed": [ + "[class*='file-chip'][class*='error']", + "[class*='file-chip'][class*='failed']", + "[class*='chip'][class*='error']", + "[class*='chip'][class*='failed']", + "[class*='chip'] [aria-label*='failed' i]", + "[class*='chip'] [aria-label*='retry' i]" + ], "ChatLimitNotice": [ "[class*='conversation-limit']", "[class*='chat-limit']", diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Resources/system-prompt.md b/src/Core/ImanSoftware.KeylessAgent.Core/Resources/system-prompt.md index c9439b0..662e1d1 100644 --- a/src/Core/ImanSoftware.KeylessAgent.Core/Resources/system-prompt.md +++ b/src/Core/ImanSoftware.KeylessAgent.Core/Resources/system-prompt.md @@ -2,7 +2,7 @@ You are KeylessAgent, an autonomous coding agent. You are talking to a local pro **These rules apply to EVERY response you produce in this ENTIRE conversation, starting with your very next reply. Never switch format.** -The ONLY valid block keywords are: `@@say`, `@@newfile`, `@@updatefile`, `@@editfile`, `@@movefile`, `@@deletefile`, `@@readfile`, `@@runcommand`, `@@ask`, `@@commit`, `@@next`, `@@done` — plus the terminator `@@eof`. There is NO `@@command` block, NO JSON envelope, NO other keyword. If you invent a keyword, the reply is rejected and nothing executes. +The ONLY valid block keywords are: `@@say`, `@@newfile`, `@@updatefile`, `@@editfile`, `@@mkdir`, `@@movefile`, `@@deletefile`, `@@readfile`, `@@runcommand`, `@@ask`, `@@commit`, `@@next`, `@@done` — plus the terminator `@@eof`. There is NO `@@command` block, NO JSON envelope, NO other keyword. If you invent a keyword, the reply is rejected and nothing executes. **You DO have the power to execute commands on the user's machine**: emit an `@@runcommand` block and the program runs it and sends you the output as `[KEYLESSAGENT RESULTS]`. NEVER say "I can't execute commands", NEVER give the user commands to run themselves, and NEVER reply with advice-only. To do anything to the workspace (create files/folders, edit, build, test), emit the corresponding blocks yourself. @@ -39,6 +39,34 @@ full file content CONTENT>>> @@end +## CONTENT payload MUST be wrapped in a code fence + +Every `<<>>` marker. The program strips the outer fence before writing the file, and INSIDE the fence newlines are preserved exactly. Without the fence, the chat's markdown renderer collapses your newlines into spaces and the reply is rejected. + +CORRECT: + +``` +@@newfile src/login.html +<< +``` +CONTENT>>> +@@end +``` + +This is the only way to write file content. Writing CONTENT without the inner backtick fence is rejected because the chat markdown renderer collapses newlines before the parser sees the reply. + +WRONG (newlines will be destroyed — this reply is rejected): + +``` +@@newfile src/login.html +<< +CONTENT>>> +@@end +``` + @@updatefile src/Foo/Bar.cs <<>> @@readfile src/Foo/Bar.cs @@end +@@mkdir src/Foo/Generated +@@end + @@movefile src/Foo/Bar.cs <<>> ## Limits (hard, enforced by the program) -- At most {{MAX_COMMANDS_PER_RESPONSE}} non-`say` commands per response. Extra commands are skipped and reported; resend them in your next response. +- Start the task with one `say` block containing a short numbered plan, then execute step 1. +- {{PACE_RULE}} +- At most {{MAX_COMMANDS_PER_RESPONSE}} non-`say` commands per response. Extra commands are skipped and reported. - The whole response must stay under {{MAX_RESPONSE_CHARS}} characters. Each file payload must stay under {{MAX_FILE_CONTENT_CHARS}} characters. Split big files across several `editfile` responses if needed. - Command output sent back to you is capped at {{MAX_COMMAND_OUTPUT_CHARS}} characters (head and tail kept). - A turn processes at most {{MAX_ITERATIONS_PER_TURN}} of your responses. Keep going across several responses for big tasks — do not try to do everything at once. @@ -113,8 +146,11 @@ MSG>>> - Call `readfile` before editing a file you have not seen in this conversation. - Prefer `editfile` over `updatefile` for small changes. - After code changes, run the build/tests (`runcommand`) and fix what they report. +- Missing tools: never use python/node/shell commands to edit, move or create files when a file command exists; if a needed tool is missing (for example dotnet for a build), say so in a `say` block, skip that verification, finish the rest, and tell the user what to install. Never retry a tool the Environment lists as missing. - Use `commit` only when the user asked for it or when the task is finished. Commit messages are short English. - `runcommand` takes one command line; segments split on `&&`, `||`, `;`, `|` are each checked against the policy. Destructive commands are always denied. +- Start the task with one `say` block containing a short numbered plan, then execute step 1. +- {{PACE_RULE}} - Use `@@next` to get results from this batch before continuing; use `@@done` to end the turn. Do not send both in one reply. - End every completed task with `@@done`. diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Chrome/ChromeCommand.cs b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Chrome/ChromeCommand.cs index c33019b..69ebf0e 100644 --- a/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Chrome/ChromeCommand.cs +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Chrome/ChromeCommand.cs @@ -47,7 +47,17 @@ public override async Task ExecuteAsync(CommandContext context, ChromeSetti } var kind = connection.Data.Attachment.Kind; + + // A hidden window (minimized/offscreen) would make the login page invisible: bring it + // into view BEFORE the ready message. Pure CDP window move — no automation, no navigation. + var windowOutcome = await KeylessAgentServiceFactory.CreateChromeWindowController( + KeylessAgentServiceFactory.CreateFileLoggerFactory(workspace)).ShowAsync(browser, 0, cancellationToken); + if (windowOutcome.IsFailure) + ConsoleWriter.Shared.Warning($"The Chrome window could not be shown: {windowOutcome.Error.Message}"); + ConsoleWriter.Shared.Success($"Chrome is ready ({kind}). Log in to DeepSeek in that window once — the login persists in the dedicated profile."); + if (windowOutcome.IsSuccess) + ConsoleWriter.Shared.Info("Chrome window shown. 'keyless hide ' moves it offscreen again."); ConsoleWriter.Shared.Info($"Profile: {connection.Data.Attachment.UserDataDir}"); ConsoleWriter.Shared.Info("You can now run 'keyless run '. This command does not automate the page."); return 0; diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Config/ConfigCommand.cs b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Config/ConfigCommand.cs index e348b79..ae5f6d3 100644 --- a/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Config/ConfigCommand.cs +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Config/ConfigCommand.cs @@ -38,11 +38,13 @@ public override async Task ExecuteAsync(CommandContext context, ConfigSetti table.AddColumn("[grey]Values[/]"); table.AddRow("Mode", options.Mode.ToString()); - table.AddRow("Browser", $"port={options.Browser.ChromeDebugPort}, DeepThink={options.Browser.EnableDeepThink}, Search={options.Browser.EnableSearch}, selectors={options.Browser.SelectorsFile}, replyTimeout={options.Browser.ReplyTimeoutSeconds}s, rate={options.Browser.MaxMessagesPerMinute}/min, debug={options.Browser.Debug}"); + table.AddRow("Browser", $"port={options.Browser.ChromeDebugPort}, windowMode={options.Browser.WindowMode}, minimizeAfterLogin={options.Browser.MinimizeAfterLogin}, DeepThink={options.Browser.EnableDeepThink}, Search={options.Browser.EnableSearch}, selectors={options.Browser.SelectorsFile}, replyTimeout={options.Browser.ReplyTimeoutSeconds}s, rate={options.Browser.MaxMessagesPerMinute}/min, debug={options.Browser.Debug}"); table.AddRow("Limits", $"commands/response={options.Limits.MaxCommandsPerResponse}, responseChars={options.Limits.MaxResponseChars}, fileChars={options.Limits.MaxFileContentChars}, outputChars={options.Limits.MaxCommandOutputChars}, formatRetries={options.Limits.MaxFormatRetries}, iterations={options.Limits.MaxIterationsPerTurn}"); table.AddRow("Snapshot", $"mode={options.Snapshot.Mode}, attachOnFeedback={options.Snapshot.AttachOnFeedback}, refreshAfterEachBatch={options.Snapshot.RefreshAfterEachBatch}"); - table.AddRow("Git", $"prefix={options.Git.BranchPrefix}, protected=[{string.Join(", ", options.Git.ProtectedBranchPatterns)}]"); - table.AddRow("Commands", $"allowlist=[{string.Join(", ", options.Commands.Allowlist)}], timeout={options.Commands.TimeoutSeconds}s"); + // Table cells render as Spectre markup: the bracket-joined lists MUST be escaped, + // or "[main, master, release*]" parses as a (malformed) markup tag. + table.AddRow("Git", $"prefix={options.Git.BranchPrefix}, protected=[{string.Join(", ", options.Git.ProtectedBranchPatterns)}]".EscapeMarkup()); + table.AddRow("Commands", $"allowlist=[{string.Join(", ", options.Commands.Allowlist)}], timeout={options.Commands.TimeoutSeconds}s".EscapeMarkup()); AnsiConsole.Write(table); return 0; diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Diagnostics/DiagnosticsChromeCommand.cs b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Diagnostics/DiagnosticsChromeCommand.cs new file mode 100644 index 0000000..667e7d5 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Diagnostics/DiagnosticsChromeCommand.cs @@ -0,0 +1,129 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Text.RegularExpressions; +using ImanSoftware.KeylessAgent.Cli.Helpers; +using Spectre.Console.Cli; + +namespace ImanSoftware.KeylessAgent.Cli.Commands.Diagnostics; + +internal sealed class DiagnosticsChromeSettings : CommandSettings +{ + [CommandArgument(0, "[workspace]")] + [Description("Path of the workspace (prompted when omitted; used for the header only).")] + public string? Workspace { get; init; } +} + +/// +/// Prints the RUNNING Chrome's full command line (Get-CimInstance Win32_Process) so the user +/// can verify the anti-occlusion flags are present, then a verdict for the two flags that +/// matter: --disable-features=CalculateNativeWinOcclusion and --window-position. +/// +internal sealed class DiagnosticsChromeCommand : AsyncCommand +{ + private static readonly Regex ProcessIdLine = new(@"ProcessId\s*:\s*(\d+)", RegexOptions.Compiled); + private static readonly Regex CommandLineLine = new(@"CommandLine\s*:\s*(.+)$", RegexOptions.Compiled); + + public override async Task ExecuteAsync(CommandContext context, DiagnosticsChromeSettings settings, CancellationToken cancellationToken) + { + // First input: the workspace path — never a silent current-directory assumption. + var workspace = WorkspacePrompt.ResolveOrPrompt(settings.Workspace); + ConsoleWriter.Shared.Header(workspace); + + if (!OperatingSystem.IsWindows()) + { + ConsoleWriter.Shared.Error("This diagnostic is Windows-only (Get-CimInstance Win32_Process)."); + return 1; + } + + var writer = ConsoleWriter.Shared; + var processes = await ReadChromeProcessesAsync(cancellationToken); + if (processes.Count == 0) + { + writer.Warning("No chrome.exe processes found. Run 'keyless chrome ' first, then re-run this command."); + return 1; + } + + writer.Info($"chrome.exe processes found: {processes.Count}. Full command lines:"); + foreach (var (pid, commandLine) in processes) + writer.Info($" PID {pid}: {commandLine ?? "(command line unavailable)"}"); + + // The two lines that matter for the offscreen-throttling diagnosis. + var withFeatures = processes.Where(p => p.CommandLine?.Contains("disable-features", StringComparison.OrdinalIgnoreCase) == true).ToList(); + var withPosition = processes.Where(p => p.CommandLine?.Contains("window-position", StringComparison.OrdinalIgnoreCase) == true).ToList(); + + writer.Info("Lines containing 'disable-features':"); + foreach (var (_, commandLine) in withFeatures) + writer.Info($" {commandLine}"); + if (withFeatures.Count == 0) + writer.Warning(" (none)"); + + writer.Info("Lines containing 'window-position':"); + foreach (var (_, commandLine) in withPosition) + writer.Info($" {commandLine}"); + if (withPosition.Count == 0) + writer.Warning(" (none)"); + + var occlusionDisabled = withFeatures.Any(p => + p.CommandLine!.Contains("CalculateNativeWinOcclusion", StringComparison.OrdinalIgnoreCase)); + + if (occlusionDisabled) + writer.Success("Verdict: --disable-features contains CalculateNativeWinOcclusion — the occlusion feature is disabled for this Chrome."); + else + writer.Warning("Verdict: CalculateNativeWinOcclusion is NOT in --disable-features. This Chrome was NOT launched by KeylessAgent (or an older launch) — close it and run 'keyless chrome' so the anti-throttling flags apply."); + + if (withPosition.Count == 0) + writer.Warning("Verdict: no --window-position flag — the window position comes from CDP moves (this is fine when Chrome was attached rather than launched)."); + return 0; + } + + /// + /// Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | Select ProcessId, CommandLine | Format-List, + /// parsed into (pid, command line) pairs. + /// + private static async Task> ReadChromeProcessesAsync(CancellationToken cancellationToken) + { + var results = new List<(int Pid, string? CommandLine)>(); + try + { + var psi = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = "-NoProfile -NonInteractive -Command \"Get-CimInstance Win32_Process -Filter \\\"Name='chrome.exe'\\\" | Select-Object ProcessId, CommandLine | Format-List\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + using var process = Process.Start(psi); + if (process is null) + return results; + + var output = await process.StandardOutput.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + int? currentPid = null; + foreach (var line in output.Split('\n')) + { + var pidMatch = ProcessIdLine.Match(line); + if (pidMatch.Success) + { + currentPid = int.Parse(pidMatch.Groups[1].Value); + continue; + } + + var commandMatch = CommandLineLine.Match(line.TrimEnd('\r', '\n')); + if (commandMatch.Success && currentPid is not null) + { + results.Add((currentPid.Value, commandMatch.Groups[1].Value.Trim())); + currentPid = null; + } + } + } + catch + { + // PowerShell missing or denied: the caller reports an empty list gracefully. + } + + return results; + } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Hide/HideCommand.cs b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Hide/HideCommand.cs new file mode 100644 index 0000000..b3be756 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Hide/HideCommand.cs @@ -0,0 +1,47 @@ +using System.ComponentModel; +using ImanSoftware.KeylessAgent.Cli.Helpers; +using ImanSoftware.KeylessAgent.Core.Factory; +using Spectre.Console.Cli; + +namespace ImanSoftware.KeylessAgent.Cli.Commands.Hide; + +internal sealed class HideSettings : CommandSettings +{ + [CommandArgument(0, "[workspace]")] + [Description("Path of the workspace whose keyless.json provides the browser settings (prompted when omitted).")] + public string? Workspace { get; init; } +} + +/// +/// Moves the Chrome window back offscreen (-32000,-32000) after a manual +/// keyless show — pure CDP window move, never disturbs a running turn. +/// +internal sealed class HideCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, HideSettings settings, CancellationToken cancellationToken) + { + // First input: the workspace path — never a silent current-directory assumption. + var workspace = WorkspacePrompt.ResolveOrPrompt(settings.Workspace); + ConsoleWriter.Shared.Header(workspace); + + var configuration = KeylessAgentServiceFactory.CreateConfigurationService(); + var options = await configuration.LoadAsync(workspace); + if (options.IsFailure) + { + ConsoleWriter.Shared.Error(options.Error.Message); + return 1; + } + + var controller = KeylessAgentServiceFactory.CreateChromeWindowController( + KeylessAgentServiceFactory.CreateFileLoggerFactory(workspace)); + var outcome = await controller.HideAsync(options.Data.Browser, cancellationToken); + if (outcome.IsFailure) + { + ConsoleWriter.Shared.Error(outcome.Error.Message); + return 1; + } + + ConsoleWriter.Shared.Success("Chrome window moved offscreen."); + return 0; + } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Interactive/InteractiveCommand.cs b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Interactive/InteractiveCommand.cs index 6730acd..ffd7884 100644 --- a/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Interactive/InteractiveCommand.cs +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Interactive/InteractiveCommand.cs @@ -49,8 +49,9 @@ public override async Task ExecuteAsync(CommandContext context, Interactive var session = new InteractiveSession( AnsiConsole.Console, (currentWorkspace, request, turnCancellationToken) => - AgentCliRunner.RunTurnAsync(currentWorkspace, request, dryRun: false, settings.Verbose, turnCancellationToken, timing: settings.Timing), - SwitchWorkspace); + AgentCliRunner.RunTurnAsync(currentWorkspace, request, dryRun: false, settings.Verbose, turnCancellationToken, timing: settings.Timing, paceOverride: settings.Pace), + SwitchWorkspace, + KeylessAgentServiceFactory.CreateEnvironmentTools()); var (exitCode, _) = await session.RunAsync(workspace, cancellationToken); return exitCode; diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Interactive/InteractiveSettings.cs b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Interactive/InteractiveSettings.cs index 1074d44..7f0f625 100644 --- a/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Interactive/InteractiveSettings.cs +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Interactive/InteractiveSettings.cs @@ -16,4 +16,9 @@ internal sealed class InteractiveSettings : CommandSettings [CommandOption("--timing")] [Description("Print the per-phase timing table after each turn.")] public bool Timing { get; init; } -} + + + [CommandOption("--pace")] + [Description("Reply pacing: Step (1 command per response), Balanced (3) or Batch (Limits.MaxCommandsPerResponse). Default Balanced.")] + public ImanSoftware.KeylessAgent.Abstractions.Models.Enums.AgentPace? Pace { get; init; } +} \ No newline at end of file diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Run/RunCommand.cs b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Run/RunCommand.cs index 9578fad..29b1d7e 100644 --- a/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Run/RunCommand.cs +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Run/RunCommand.cs @@ -1,4 +1,6 @@ using ImanSoftware.KeylessAgent.Cli.Helpers; +using ImanSoftware.KeylessAgent.Core.Factory; +using ImanSoftware.KeylessAgent.Core.Implementation.Workspace; using Spectre.Console; using Spectre.Console.Cli; @@ -16,12 +18,21 @@ public override async Task ExecuteAsync(CommandContext context, RunSettings ConsoleWriter.Shared.Header(workspace); + // One explicit tool status per session: git/dotnet/python/node with download pages + // for anything missing (served from the same 24 h cache the agent loop uses). + var environmentTools = KeylessAgentServiceFactory.CreateEnvironmentTools(); + var tools = await environmentTools.GetToolsCachedAsync(workspace, settings.RefreshEnv, cancellationToken); + ConsoleWriter.Shared.Info(IEnvironmentTools.FormatToolsLine(tools)); + ConsoleWriter.Shared.Info($"Pace: {settings.Pace?.ToString() ?? "Balanced (default)"} — non-say commands per response: " + + (settings.Pace == ImanSoftware.KeylessAgent.Abstractions.Models.Enums.AgentPace.Step ? "1" : settings.Pace == ImanSoftware.KeylessAgent.Abstractions.Models.Enums.AgentPace.Batch ? "Limits.MaxCommandsPerResponse" : "3")); + using var turnCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); Console.CancelKeyPress += OnCancelKeyPress; try { return await AgentCliRunner.RunTurnAsync( - workspace, request, settings.DryRun, settings.Verbose, turnCts.Token, timing: settings.Timing); + workspace, request, settings.DryRun, settings.Verbose, turnCts.Token, timing: settings.Timing, + refreshEnv: settings.RefreshEnv, paceOverride: settings.Pace); } finally { diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Run/RunSettings.cs b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Run/RunSettings.cs index 2109d84..dc9727c 100644 --- a/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Run/RunSettings.cs +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Run/RunSettings.cs @@ -24,4 +24,12 @@ internal sealed class RunSettings : CommandSettings [CommandOption("--timing")] [Description("Print the per-phase timing table after the turn.")] public bool Timing { get; init; } + + [CommandOption("--refresh-env")] + [Description("Re-probe the environment (git/dotnet/node/npm/python) instead of using the 24 h cache.")] + public bool RefreshEnv { get; init; } + + [CommandOption("--pace")] + [Description("Reply pacing: Step (1 command per response), Balanced (3) or Batch (Limits.MaxCommandsPerResponse). Default Balanced.")] + public ImanSoftware.KeylessAgent.Abstractions.Models.Enums.AgentPace? Pace { get; init; } } diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Selectors/SelectorsRefreshCommand.cs b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Selectors/SelectorsRefreshCommand.cs new file mode 100644 index 0000000..1a1c3e9 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Selectors/SelectorsRefreshCommand.cs @@ -0,0 +1,63 @@ +using System.ComponentModel; +using ImanSoftware.KeylessAgent.Cli.Helpers; +using ImanSoftware.KeylessAgent.Core.Factory; +using ImanSoftware.KeylessAgent.Core.Implementation.Browser; +using Spectre.Console.Cli; + +namespace ImanSoftware.KeylessAgent.Cli.Commands.Selectors; + +internal sealed class SelectorsRefreshSettings : CommandSettings +{ + [CommandArgument(0, "[workspace]")] + [Description("Path of the workspace whose selectors.json is refreshed (prompted when omitted).")] + public string? Workspace { get; init; } +} + +/// +/// Forces the selectors.json merge UNCONDITIONALLY (ignoring the CatalogVersion check): +/// never-customized keys are refreshed to the current embedded defaults, genuine user +/// customizations survive, and the original file is backed up as selectors.json.v<N>.bak. +/// For picking up new selectors immediately instead of waiting for the next load. +/// +internal sealed class SelectorsRefreshCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, SelectorsRefreshSettings settings, CancellationToken cancellationToken) + { + // First input: the workspace path — never a silent current-directory assumption. + var workspace = WorkspacePrompt.ResolveOrPrompt(settings.Workspace); + ConsoleWriter.Shared.Header(workspace); + + var configuration = KeylessAgentServiceFactory.CreateConfigurationService(); + var options = await configuration.LoadAsync(workspace); + if (options.IsFailure) + { + ConsoleWriter.Shared.Error(options.Error.Message); + return 1; + } + + var loggerFactory = KeylessAgentServiceFactory.CreateFileLoggerFactory(workspace); + var merge = await SelectorsCatalog.MergeAsync( + workspace, options.Data.Browser.SelectorsFile, forceRefresh: true, loggerFactory, cancellationToken); + if (merge.IsFailure) + { + ConsoleWriter.Shared.Error(merge.Error.Message); + return 1; + } + + var refreshed = merge.Data.RefreshedKeys; + var writer = ConsoleWriter.Shared; + if (refreshed.Count == 0) + { + writer.Success("selectors.json refreshed: no keys needed an update — everything is current or user-customized."); + } + else + { + writer.Success($"selectors.json refreshed: {refreshed.Count} key(s) updated to the current embedded defaults:"); + foreach (var key in refreshed) + writer.Info($" • {key}"); + } + + writer.Info("A backup of the previous file was written next to it (selectors.json.v.bak)."); + return 0; + } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Show/ShowCommand.cs b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Show/ShowCommand.cs new file mode 100644 index 0000000..7d7a8f2 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/Commands/Show/ShowCommand.cs @@ -0,0 +1,54 @@ +using System.ComponentModel; +using ImanSoftware.KeylessAgent.Cli.Helpers; +using ImanSoftware.KeylessAgent.Core.Factory; +using Spectre.Console.Cli; + +namespace ImanSoftware.KeylessAgent.Cli.Commands.Show; + +internal sealed class ShowSettings : CommandSettings +{ + [CommandArgument(0, "[workspace]")] + [Description("Path of the workspace whose keyless.json provides the browser settings (prompted when omitted).")] + public string? Workspace { get; init; } + + [CommandOption("--peek ")] + [Description("Show the window for this many seconds, then move it back offscreen (0 = stay visible).")] + [DefaultValue(0)] + public int Peek { get; init; } +} + +/// +/// Brings the offscreen/minimized Chrome window into view on demand (pure CDP window move — +/// a running turn is never disturbed). --peek N returns it offscreen after N seconds. +/// +internal sealed class ShowCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, ShowSettings settings, CancellationToken cancellationToken) + { + // First input: the workspace path — never a silent current-directory assumption. + var workspace = WorkspacePrompt.ResolveOrPrompt(settings.Workspace); + ConsoleWriter.Shared.Header(workspace); + + var configuration = KeylessAgentServiceFactory.CreateConfigurationService(); + var options = await configuration.LoadAsync(workspace); + if (options.IsFailure) + { + ConsoleWriter.Shared.Error(options.Error.Message); + return 1; + } + + var controller = KeylessAgentServiceFactory.CreateChromeWindowController( + KeylessAgentServiceFactory.CreateFileLoggerFactory(workspace)); + var outcome = await controller.ShowAsync(options.Data.Browser, Math.Max(0, settings.Peek), cancellationToken); + if (outcome.IsFailure) + { + ConsoleWriter.Shared.Error(outcome.Error.Message); + return 1; + } + + ConsoleWriter.Shared.Success(settings.Peek > 0 + ? $"Chrome window shown. It will return offscreen in {settings.Peek} seconds." + : $"Chrome window shown. Run 'keyless hide {workspace}' or close the window when done."); + return 0; + } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/ConsoleUserInterface.cs b/src/UI/ImanSoftware.KeylessAgent.Cli/ConsoleUserInterface.cs index 403c412..60b5f1f 100644 --- a/src/UI/ImanSoftware.KeylessAgent.Cli/ConsoleUserInterface.cs +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/ConsoleUserInterface.cs @@ -214,6 +214,10 @@ public Task ConfirmAsync(ConfirmRequest request) { var result = WithGate(() => { + // The BEL rings the terminal bell / flashes the taskbar icon — the user's eyes + // stay wherever they are; no window is raised or focused. + Console.Out.Write('\a'); + Console.Out.Flush(); var prompt = new ConfirmationPrompt( $"[yellow]?[/] {request.Title.EscapeMarkup()}: {request.Details.EscapeMarkup()}"); return prompt.Show(_console); diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/Helpers/AgentCliRunner.cs b/src/UI/ImanSoftware.KeylessAgent.Cli/Helpers/AgentCliRunner.cs index 10de969..89d3e36 100644 --- a/src/UI/ImanSoftware.KeylessAgent.Cli/Helpers/AgentCliRunner.cs +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/Helpers/AgentCliRunner.cs @@ -31,7 +31,9 @@ public static async Task RunTurnAsync( bool verbose, CancellationToken cancellationToken = default, bool timing = false, - IChatProvider? chatProvider = null) + IChatProvider? chatProvider = null, + bool refreshEnv = false, + ImanSoftware.KeylessAgent.Abstractions.Models.Enums.AgentPace? paceOverride = null) { ILoggerFactory? loggerFactory = null; if (verbose) @@ -51,7 +53,7 @@ public static async Task RunTurnAsync( try { - var result = await loop.RunAsync(workspaceRoot, request, ui, dryRun, cancellationToken); + var result = await loop.RunAsync(workspaceRoot, request, ui, dryRun, cancellationToken, refreshEnv, paceOverride); AnsiConsole.WriteLine(); if (result.ErrorMessage is not null) diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/Helpers/InteractiveSession.cs b/src/UI/ImanSoftware.KeylessAgent.Cli/Helpers/InteractiveSession.cs index cfee8bc..196a004 100644 --- a/src/UI/ImanSoftware.KeylessAgent.Cli/Helpers/InteractiveSession.cs +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/Helpers/InteractiveSession.cs @@ -12,6 +12,8 @@ internal sealed class InteractiveSession private readonly IAnsiConsole _console; private readonly Func> _runTurn; private readonly Func> _switchWorkspace; + private readonly ImanSoftware.KeylessAgent.Core.Implementation.Workspace.IEnvironmentTools? _environmentTools; + private bool _toolsLinePrinted; /// Set when a graceful exit was requested (idle Ctrl+C or the second press). private readonly TaskCompletionSource _exitRequested = @@ -34,11 +36,13 @@ internal sealed class InteractiveSession public InteractiveSession( IAnsiConsole console, Func> runTurn, - Func> switchWorkspace) + Func> switchWorkspace, + ImanSoftware.KeylessAgent.Core.Implementation.Workspace.IEnvironmentTools? environmentTools = null) { _console = console; _runTurn = runTurn; _switchWorkspace = switchWorkspace; + _environmentTools = environmentTools; } /// @@ -73,6 +77,22 @@ internal void RequestCancel() public async Task<(int ExitCode, string Workspace)> RunAsync(string workspace, CancellationToken cancellationToken) { Console.CancelKeyPress += OnCancelKeyPress; + + // One explicit tool status per session (git/dotnet/python/node with download pages). + if (_environmentTools is not null && !_toolsLinePrinted) + { + _toolsLinePrinted = true; + try + { + var tools = await _environmentTools.GetToolsCachedAsync(workspace, forceRefresh: false, cancellationToken); + _console.WriteLine(ImanSoftware.KeylessAgent.Core.Implementation.Workspace.IEnvironmentTools.FormatToolsLine(tools)); + } + catch + { + // The tools line is informational; a probe failure never blocks the session. + } + } + try { while (true) @@ -167,6 +187,21 @@ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e) PrintHelp(); return null; + case "/tools": + case "/refreshenv": + if (_environmentTools is null) + { + _console.MarkupLine("[yellow][[!]][/] Environment tools are not available in this session."); + return null; + } + + var refreshed = await _environmentTools.GetToolsCachedAsync( + workspace, forceRefresh: command == "/refreshenv", cancellationToken); + _console.WriteLine(ImanSoftware.KeylessAgent.Core.Implementation.Workspace.IEnvironmentTools.FormatToolsLine(refreshed)); + if (command == "/refreshenv") + _console.MarkupLine("[cyan]Environment re-probed.[/]"); + return null; + case "/newchat": var clearError = await _switchWorkspace(workspace, workspace, cancellationToken); if (clearError is not null) @@ -213,6 +248,8 @@ private void PrintHelp() _console.MarkupLine("[cyan]Available commands[/]"); _console.MarkupLine(" [white]/workspace [/] Switch to another workspace (validated; fresh chat session)"); _console.MarkupLine(" [white]/newchat[/] Start a fresh chat in the same workspace"); + _console.MarkupLine(" [white]/tools[/] Show the installed tools (git/dotnet/python/node)"); + _console.MarkupLine(" [white]/refreshenv[/] Re-probe the environment tools"); _console.MarkupLine(" [white]/help[/] Show this list"); _console.MarkupLine("[grey]Anything else is sent to the agent as a request. An empty line quits.[/]"); } diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/ImanSoftware.KeylessAgent.Cli.csproj b/src/UI/ImanSoftware.KeylessAgent.Cli/ImanSoftware.KeylessAgent.Cli.csproj index 920c651..d347d56 100644 --- a/src/UI/ImanSoftware.KeylessAgent.Cli/ImanSoftware.KeylessAgent.Cli.csproj +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/ImanSoftware.KeylessAgent.Cli.csproj @@ -1,10 +1,12 @@ + Exe $(NoWarn);CA1812;S6966 - + app.manifest + @@ -27,6 +29,7 @@ true keyless ImanSoftware.KeylessAgent.Cli + KeylessAgent: a coding agent that works through your own logged-in DeepSeek web chat instead of an API key. Applies model replies to your workspace using a strict command language, on a protected agent branch. ai-agent;coding-agent;deepseek;playwright;cli;dotnet-tool;keyless icon.png diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/Program.cs b/src/UI/ImanSoftware.KeylessAgent.Cli/Program.cs index 0159182..a46c0e8 100644 --- a/src/UI/ImanSoftware.KeylessAgent.Cli/Program.cs +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/Program.cs @@ -1,8 +1,12 @@ using ImanSoftware.KeylessAgent.Cli.Commands.Chrome; using ImanSoftware.KeylessAgent.Cli.Commands.Config; +using ImanSoftware.KeylessAgent.Cli.Commands.Diagnostics; using ImanSoftware.KeylessAgent.Cli.Commands.Doctor; +using ImanSoftware.KeylessAgent.Cli.Commands.Hide; +using ImanSoftware.KeylessAgent.Cli.Commands.Show; using ImanSoftware.KeylessAgent.Cli.Commands.Interactive; using ImanSoftware.KeylessAgent.Cli.Commands.Resume; +using ImanSoftware.KeylessAgent.Cli.Commands.Selectors; using ImanSoftware.KeylessAgent.Cli.Commands.Run; using ImanSoftware.KeylessAgent.Cli.Commands.Undo; using Spectre.Console; @@ -52,6 +56,29 @@ .WithExample("doctor", @"G:\Projects\MyApp", "--capture", @".\doctor-capture") .WithExample("doctor", @"G:\Projects\MyApp", "--watch"); + config.AddCommand("show") + .WithDescription("Bring the offscreen/minimized Chrome window into view (pure CDP window move; never disturbs a running turn).") + .WithExample("show", @"G:\Projects\MyApp") + .WithExample("show", @"G:\Projects\MyApp", "--peek", "3"); + + config.AddCommand("hide") + .WithDescription("Move the Chrome window back offscreen after a manual show.") + .WithExample("hide", @"G:\Projects\MyApp"); + + config.AddBranch("diagnostics", branch => + { + branch.AddCommand("chrome") + .WithDescription("Print the running Chrome's command line and verify the anti-occlusion flags.") + .WithExample("diagnostics", "chrome", @"G:\Projects\MyApp"); + }); + + config.AddBranch("selectors", branch => + { + branch.AddCommand("refresh") + .WithDescription("Force-merge the workspace selectors.json with the current embedded defaults (never-customized keys refreshed, customizations preserved, backup written).") + .WithExample("selectors", "refresh", @"G:\Projects\MyApp"); + }); + config.AddCommand("config") .WithDescription("Show the effective configuration of a workspace (keyless.json is created with defaults when missing).") .WithExample("config", @"G:\Projects\MyApp"); diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/README.md b/src/UI/ImanSoftware.KeylessAgent.Cli/README.md index 28dcc49..da4e317 100644 --- a/src/UI/ImanSoftware.KeylessAgent.Cli/README.md +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/README.md @@ -28,7 +28,7 @@ keyless chrome "G:\Projects\MyApp" # start the dedicated Chrome (one-time Deep keyless doctor "G:\Projects\MyApp" # live selector check, never sends anything ``` -Inside the interactive session: `/workspace ` switches workspaces, `/newchat` starts a fresh chat in the same workspace, `/help` lists slash commands, any other `/`-input is rejected with a hint. +Inside the interactive session: `/workspace ` switches workspaces, `/newchat` starts a fresh chat in the same workspace, `/tools` shows the installed tools, `/refreshenv` re-probes them, `/help` lists slash commands, any other `/`-input is rejected with a hint. **Ctrl+C** is safe: during a turn it cancels only that turn (press again within 2 seconds to exit); at the `keyless>` prompt it exits gracefully. `keyless run` accepts `--timing` to print the per-phase timing table after the turn. @@ -40,6 +40,137 @@ Full documentation — AgentScript, configuration, selectors, reply sources, sec **[github.com/ImanEstiri/KeylessAgent](https://github.com/ImanEstiri/KeylessAgent)** +## Commands + +| Command | Purpose | +|---|---| +| `keyless run ` | Run one agent turn: snapshot → chat → execute → results | +| `keyless resume ` | Continue the chat saved in `/.agent/session.json` | +| `keyless undo ` | Restore the newest backup batch from `.agent/backups` | +| `keyless config ` | Show the effective configuration (creates `keyless.json` with defaults when missing) | +| `keyless chrome ` | Start/attach the dedicated Chrome profile and show its window (for the one-time DeepSeek login) | +| `keyless doctor ` | Live selector check against the running Chrome — never sends anything | +| `keyless show ` | Bring the hidden Chrome window into view (pure CDP move; never disturbs a running turn) | +| `keyless hide ` | Move the Chrome window back to its hidden corner | +| `keyless diagnostics chrome ` | Print the running Chrome's command line and verify the anti-throttling flags | +| `keyless selectors refresh ` | Force-merge `selectors.json` with the current embedded defaults (backup written) | + +`run`, `resume`, `undo`, `config`, `chrome`, `doctor`, `show`, `hide` prompt for the workspace path when omitted. + +### keyless run + +```bash +keyless run "G:\Projects\MyApp" "Add xUnit tests for the markdown parser" +keyless run "G:\Projects\MyApp" "Fix the failing parser test" --dry-run # commands are parsed, never executed +keyless run "G:\Projects\MyApp" "Refactor the config loader" -v # verbose progress +keyless run "G:\Projects\MyApp" "Add a README section" --timing # per-phase timing table after the turn +keyless run "G:\Projects\MyApp" "Fix the tests" --pace Step # 1 command per response (Balanced = 3, Batch = limit) +keyless run # prompts for everything +``` + +Exit codes: **0** completed, **1** failed, **2** stopped early (reason printed), **130** cancelled by the user. + +### keyless resume + +Sends the next request to the SAME DeepSeek chat saved in the workspace session (conversation context preserved): + +```bash +keyless resume "G:\Projects\MyApp" "continue with the failing test" +``` + +### keyless undo + +Restores the newest backup batch (every file a turn touched is backed up before mutation): + +```bash +keyless undo "G:\Projects\MyApp" +``` + +### keyless config + +```bash +keyless config "G:\Projects\MyApp" # prints the effective configuration as a table +``` + +The first run writes the full default `keyless.json` (edit it freely — parsing is tolerant: comments, trailing commas, case-insensitive keys). + +### keyless chrome + +Starts the dedicated Chrome profile with the debug port (mandatory since Chrome 136) and SHOWS the window so you can log in once: + +```bash +keyless chrome "G:\Projects\MyApp" +``` + +The login persists in the profile; afterwards runs keep the window hidden (TinyCorner) and reuse the login. + +### keyless doctor + +Races every selector against the live page and prints a MATCHED / NOT FOUND / NOT APPLICABLE table: + +```bash +keyless doctor "G:\Projects\MyApp" +keyless doctor "G:\Projects\MyApp" --capture ".\doctor-capture" # trimmed HTML snapshots for selector fixing +keyless doctor "G:\Projects\MyApp" --watch # capture while you send a prompt manually +``` + +### keyless show / keyless hide + +```bash +keyless show "G:\Projects\MyApp" # window appears at (100,100) 1280x900 +keyless show "G:\Projects\MyApp" --peek 3 # visible for 3 seconds, then back offscreen +keyless hide "G:\Projects\MyApp" # back to the tiny corner square +``` + +### keyless diagnostics chrome + +```bash +keyless diagnostics chrome "G:\Projects\MyApp" # prints chrome.exe command lines + flag verdict +``` + +### keyless selectors refresh + +```bash +keyless selectors refresh "G:\Projects\MyApp" # force-merge selectors.json with the embedded defaults +``` + +Never-customized keys are refreshed to the current embedded selectors, hand-edited keys survive, and the previous file is backed up as `selectors.json.v.bak`. + +### Interactive session + +`keyless` without arguments starts the interactive prompt (`keyless>`): + +```text +/workspace G:\Projects\OtherApp switch workspace +/newchat fresh DeepSeek chat in the same workspace +/tools show installed tools (git, dotnet, …) +/refreshenv re-probe the environment +/help list all slash commands +``` + +**Ctrl+C** is safe: during a turn it cancels only that turn (press again within 2 seconds to exit); at the `keyless>` prompt it exits gracefully. + +## Versioning and publishing + +The package version lives in ONE place: `` in `Directory.Build.props` (1.0.0) — the CLI assembly reads it from there and the consistency test forbids a second copy. Publishing mirrors the DepLens flow: + +1. `publish.bat` in this folder runs `publish.ps1` from the solution root. +2. The script packs the CLI (`dotnet pack -c Release`) into `artifacts/ImanSoftware.KeylessAgent.Cli`, **auto-bumping the patch version** in `Directory.Build.props` whenever an artifact for the current version already exists. +3. It then pushes the `.nupkg` (plus the `.snupkg` symbols) to NuGet.org with a 30 s timeout; on a failed/timed-out push the freshly built artifacts are removed so the version is not wasted. + +Prerequisites: `NUGET_API_KEY` set once outside of git (`setx NUGET_API_KEY your-key`, then restart the terminal). `-SkipPush` packs only. Install after publishing: + +```bash +dotnet tool install --global ImanSoftware.KeylessAgent.Cli +dotnet tool update --global ImanSoftware.KeylessAgent.Cli +``` + ## License MIT — see [LICENSE](https://github.com/ImanEstiri/KeylessAgent/blob/main/LICENSE.txt). + +## Pacing and tools + +`keyless run … --pace Step|Balanced|Batch` (and `--pace` on the interactive command) caps how many non-`say` commands one response may carry: Step = 1, Balanced = 3, Batch = `Limits.MaxCommandsPerResponse`. Skipped commands are reported with `pace=: only N command(s) per response; send the next one after reading this result`. + +Each session prints one tools line (`Tools: git OK, dotnet MISSING (https://…), …`). `runcommand`s whose tool is missing are never started (`KA_TOOL_MISSING`) — use the file commands or install the tool. `--refresh-env` re-probes the environment instead of using the 24 h cache. \ No newline at end of file diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/app.manifest b/src/UI/ImanSoftware.KeylessAgent.Cli/app.manifest new file mode 100644 index 0000000..ddf0142 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Cli/app.manifest @@ -0,0 +1,16 @@ + + + + + + + + PerMonitorV2 + + + diff --git a/src/UI/ImanSoftware.KeylessAgent.Cli/icon.png b/src/UI/ImanSoftware.KeylessAgent.Cli/icon.png index dce5ca6..a8e61dc 100644 Binary files a/src/UI/ImanSoftware.KeylessAgent.Cli/icon.png and b/src/UI/ImanSoftware.KeylessAgent.Cli/icon.png differ diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/App.xaml b/src/UI/ImanSoftware.KeylessAgent.Wpf/App.xaml new file mode 100644 index 0000000..2690430 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/App.xaml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/App.xaml.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/App.xaml.cs new file mode 100644 index 0000000..4642f76 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/App.xaml.cs @@ -0,0 +1,132 @@ +using System.IO; +using System.Windows; +using ImanSoftware.KeylessAgent.Core.Factory; +using ImanSoftware.KeylessAgent.Wpf.Services; + +namespace ImanSoftware.KeylessAgent.Wpf; + +/// +/// WPF entry point of the KeylessAgent desktop UI with global crash handling (Appendix E): +/// every unhandled exception is appended to %LOCALAPPDATA%\KeylessAgent\wpf-crash.log +/// and reported through the dialog host when it exists — the app keeps running instead of +/// closing silently. +/// +public partial class App : Application +{ + /// Initializes the global exception handlers. + /// The startup arguments. + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + DispatcherUnhandledException += (_, args) => + { + WriteCrashLog(args.Exception); + ShowErrorOnUi(args.Exception.Message); + args.Handled = false; + }; + + AppDomain.CurrentDomain.UnhandledException += (_, args) => + WriteCrashLog(args.ExceptionObject as Exception + ?? new InvalidOperationException($"Non-exception failure: {args.ExceptionObject}")); + + TaskScheduler.UnobservedTaskException += (_, args) => + { + WriteCrashLog(args.Exception); + args.SetObserved(); + }; + } + + /// + /// ALWAYS-ON safety net for the OS-level Chrome the app itself launched. DISTINCTION + /// from Browser.CloseChromeOnExit (do NOT merge the two): CloseChromeOnExit only gates + /// PlaywrightChatProvider.DisposeAsync - whether the chat TAB/CONTEXT stays open between + /// turns while the app is running. This override instead closes the actual Chrome + /// process/window on normal application exit, because the TinyCorner window is invisible + /// by design and would otherwise outlive the app unnoticed. Best-effort with a hard + /// timeout; never blocks shutdown indefinitely; never lets an exception escape. + /// + protected override void OnExit(ExitEventArgs e) + { + try + { + // The close runs on a worker (CloseAsync is async; production code must never + // block on tasks - the NoSyncOverAsync rule). Shutdown is bounded by a + // ManualResetEventSlim wait (a plain synchronization primitive, NOT Task.Wait) + // for the same 4s the worker's own cancellation token allows, so the process + // never lingers longer than that even if Chrome is unresponsive. + using var completed = new System.Threading.ManualResetEventSlim(false); + _ = Task.Run(async () => + { + try + { + using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(4)); + var controller = KeylessAgentServiceFactory.CreateChromeWindowController(); + + // Ownership comes from the RUNNING processes' command lines (profile dir + + // debug port in BrowserOptions), so the default options suffice even + // without a selected workspace; allowAttached=false never touches a + // user-launched Chrome. + var options = new Abstractions.Models.Configuration.BrowserOptions(); + await controller.CloseAsync(options, allowAttached: false, cts.Token); + } + catch + { + // Chrome cleanup must never make application shutdown fail. + } + finally + { + completed.Set(); + } + }); + + completed.Wait(TimeSpan.FromSeconds(4)); + } + catch + { + // Chrome cleanup must never make application shutdown fail or hang. + } + + base.OnExit(e); + } + + /// The crash-log directory (%LOCALAPPDATA%\KeylessAgent by default). + /// Overrides the base directory (used by tests). + internal static string GetCrashLogDirectory(string? baseDirectory = null) + => Path.Combine(baseDirectory ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "KeylessAgent"); + + /// The full crash-log path (wpf-crash.log inside the crash-log directory). + /// Overrides the base directory (used by tests). + internal static string GetCrashLogPath(string? baseDirectory = null) + => Path.Combine(GetCrashLogDirectory(baseDirectory), "wpf-crash.log"); + + /// Appends one exception to the crash log. Never throws (guide Appendix E). + /// The exception to record. + /// Overrides the base directory (used by tests). + internal static void WriteCrashLog(Exception exception, string? baseDirectory = null) + { + try + { + var line = $"{DateTime.UtcNow:O} {exception}{Environment.NewLine}{Environment.NewLine}"; + Directory.CreateDirectory(GetCrashLogDirectory(baseDirectory)); + File.AppendAllText(GetCrashLogPath(baseDirectory), line); + } + catch + { + // Crash logging must never throw; the failure is dropped deliberately. + } + } + + private static void ShowErrorOnUi(string message) + { + var dispatcher = Current?.Dispatcher; + if (dispatcher is null) + return; + + _ = dispatcher.BeginInvoke(() => + { + if (!DialogHost.HasError) + DialogHost.ShowError(message); + }); + } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/ImanSoftware.KeylessAgent.Wpf.csproj b/src/UI/ImanSoftware.KeylessAgent.Wpf/ImanSoftware.KeylessAgent.Wpf.csproj new file mode 100644 index 0000000..0d8d293 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/ImanSoftware.KeylessAgent.Wpf.csproj @@ -0,0 +1,27 @@ + + + + WinExe + + net10.0-windows + true + + app.manifest + icon.ico + + false + ImanSoftware.KeylessAgent.Wpf + ImanSoftware.KeylessAgent.Wpf + + $(NoWarn);CA1812;CA1515 + + + + + + + + + + + diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/BackdropPlanner.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/BackdropPlanner.cs new file mode 100644 index 0000000..f0a2a27 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/BackdropPlanner.cs @@ -0,0 +1,86 @@ +namespace ImanSoftware.KeylessAgent.Wpf.Infrastructure; + +/// The system backdrop kinds the DWM can draw behind the window. +internal enum BackdropKind +{ + /// No system backdrop; the app draws its own gradient. + None = 0, + + /// Windows 11 Mica material (subtle, tinted by the desktop). + Mica = 2, + + /// Windows 11 Acrylic material (more translucent, blurrier). + Acrylic = 3, +} + +/// The user's backdrop preference (settings UI arrives in Phase 4). +internal enum BackdropPreference +{ + /// Pick the best material the OS supports (Mica on 22H2+, else none). + Auto = 0, + + /// Force Mica when supported. + Mica = 1, + + /// Force Acrylic when supported. + Acrylic = 2, + + /// No system backdrop; the app draws its own gradient. + None = 3, +} + +/// The visual plan a given OS/preference/effect combination produces. +/// The system backdrop kind to request (None = app-drawn gradient). +/// Whether the blurred orb layer is rendered. +/// Whether the app gradient is shown behind the content. +/// Whether entrance/hover animations and the pulsing dot run. +internal sealed record BackdropPlan( + BackdropKind Backdrop, + bool ShowOrbs, + bool ShowFallbackGradient, + bool Animations); + +/// +/// Pure decision function for the backdrop: no WPF or OS calls, fully unit tested. +/// +internal static class BackdropPlanner +{ + /// Windows 11 22H2 (build 22621) introduced DWMWA_SYSTEMBACKDROP_TYPE. + public const int MinSystemBackdropBuild = 22621; + + /// + /// Decides the visual plan for a Windows build, the user's backdrop preference and the + /// "Reduce effects" toggle. + /// + /// The OS build number (Environment.OSVersion.Version.Build). + /// The user's backdrop preference (Auto, Mica, Acrylic, None). + /// True when the user turned orbs/animations off. + public static BackdropPlan Decide(int osBuild, string preference, bool reduceEffects) + { + if (reduceEffects) + return new BackdropPlan(BackdropKind.None, ShowOrbs: false, ShowFallbackGradient: true, Animations: false); + + var supportsSystemBackdrop = osBuild >= MinSystemBackdropBuild; + if (!supportsSystemBackdrop) + { + // Windows 10 and early Windows 11: no system backdrop — the blurred orbs provide + // the frosted look over the app gradient instead. + return new BackdropPlan(BackdropKind.None, ShowOrbs: true, ShowFallbackGradient: true, Animations: true); + } + + var preferenceKind = Parse(preference); + if (preferenceKind == BackdropPreference.None) + return new BackdropPlan(BackdropKind.None, ShowOrbs: false, ShowFallbackGradient: true, Animations: true); + + var backdrop = preferenceKind == BackdropPreference.Acrylic + ? BackdropKind.Acrylic + : BackdropKind.Mica; // Auto and Mica both land on Mica (the calm default). + + return new BackdropPlan(backdrop, ShowOrbs: true, ShowFallbackGradient: false, Animations: true); + } + + private static BackdropPreference Parse(string preference) + => Enum.TryParse(preference, ignoreCase: true, out var parsed) + ? parsed + : BackdropPreference.Auto; +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/FeedScrollBehavior.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/FeedScrollBehavior.cs new file mode 100644 index 0000000..8553daa --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/FeedScrollBehavior.cs @@ -0,0 +1,60 @@ +using System.Collections.Specialized; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; + +namespace ImanSoftware.KeylessAgent.Wpf.Infrastructure; + +/// +/// Attached behavior that scrolls an ItemsControl's host ScrollViewer to the end whenever new +/// items arrive (feed auto-scroll). Pure view logic — no business code in code-behind. +/// +public static class FeedScrollBehavior +{ + /// Identifies the Enabled attached property. + public static readonly DependencyProperty EnabledProperty = DependencyProperty.RegisterAttached( + "Enabled", typeof(bool), typeof(FeedScrollBehavior), new PropertyMetadata(false, OnEnabledChanged)); + + /// Sets whether the ItemsControl auto-scrolls on collection changes. + /// The ItemsControl. + /// True to enable auto-scroll. + public static void SetEnabled(DependencyObject element, bool value) + { + ArgumentNullException.ThrowIfNull(element); + element.SetValue(EnabledProperty, value); + } + + /// Gets whether the ItemsControl auto-scrolls on collection changes. + /// The ItemsControl. + public static bool GetEnabled(DependencyObject element) + { + ArgumentNullException.ThrowIfNull(element); + return (bool)element.GetValue(EnabledProperty); + } + + private static void OnEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not ItemsControl control || !(bool)e.NewValue) + return; + + if (control.ItemsSource is INotifyCollectionChanged changed) + changed.CollectionChanged += (_, _) => ScrollToEnd(control); + + control.Loaded += (_, _) => ScrollToEnd(control); + } + + private static void ScrollToEnd(ItemsControl control) + { + var node = (DependencyObject?)control; + while (node is not null) + { + if (node is ScrollViewer viewer) + { + viewer.ScrollToEnd(); + return; + } + + node = VisualTreeHelper.GetParent(node); + } + } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/IUiDispatcher.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/IUiDispatcher.cs new file mode 100644 index 0000000..1a4e14f --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/IUiDispatcher.cs @@ -0,0 +1,15 @@ +namespace ImanSoftware.KeylessAgent.Wpf.Infrastructure; + +/// +/// Marshals work onto the WPF UI thread. Abstracted so view models stay testable: +/// tests run against a synchronous fake instead of a real WPF dispatcher. +/// +public interface IUiDispatcher +{ + /// Queues fire-and-forget work on the UI thread; never blocks the caller. + void Post(Action action); + + /// Runs work on the UI thread and completes with its result. + /// Result type of the work. + Task InvokeAsync(Func func); +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/InverseBoolToVisibilityConverter.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/InverseBoolToVisibilityConverter.cs new file mode 100644 index 0000000..3213621 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/InverseBoolToVisibilityConverter.cs @@ -0,0 +1,20 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace ImanSoftware.KeylessAgent.Wpf.Infrastructure; + +/// +/// Converts a bool to its INVERTED visibility: true → Collapsed, false → Visible. Used for +/// empty-state text that shows while a collection or flag is empty. +/// +public sealed class InverseBoolToVisibilityConverter : IValueConverter +{ + /// + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is true ? Visibility.Collapsed : Visibility.Visible; + + /// + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/NarrowPanelVisibilityConverter.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/NarrowPanelVisibilityConverter.cs new file mode 100644 index 0000000..b23152b --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/NarrowPanelVisibilityConverter.cs @@ -0,0 +1,23 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace ImanSoftware.KeylessAgent.Wpf.Infrastructure; + +/// +/// Converts the window's ActualWidth to the visibility of the side panel: the panel collapses +/// on narrow windows (below 1100 px, guide Phase 6) and is visible otherwise. +/// +public sealed class NarrowPanelVisibilityConverter : IValueConverter +{ + /// Window widths below this are considered narrow. + public const double NarrowThreshold = 1100; + + /// + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is double width && width < NarrowThreshold ? Visibility.Collapsed : Visibility.Visible; + + /// + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/NullToCollapsedConverter.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/NullToCollapsedConverter.cs new file mode 100644 index 0000000..96aceb4 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/NullToCollapsedConverter.cs @@ -0,0 +1,17 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace ImanSoftware.KeylessAgent.Wpf.Infrastructure; + +/// Converts null to Collapsed and any non-null value to Visible (optional-value rows). +public sealed class NullToCollapsedConverter : IValueConverter +{ + /// + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is null ? Visibility.Collapsed : Visibility.Visible; + + /// + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/PathText.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/PathText.cs new file mode 100644 index 0000000..561607c --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/PathText.cs @@ -0,0 +1,20 @@ +namespace ImanSoftware.KeylessAgent.Wpf.Infrastructure; + +/// Pure helpers for displaying long file-system paths. +public static class PathText +{ + /// Default display length before trimming. + public const int DefaultMaxLength = 48; + + /// Trims a path in the middle ("G:\Projects\…\demo") so both ends stay readable. + /// The full path; null yields an empty string. + /// Display length at which trimming starts. + public static string MiddleTrim(string? path, int maxLength = DefaultMaxLength) + { + if (string.IsNullOrEmpty(path) || path.Length <= maxLength) + return path ?? string.Empty; + + var half = Math.Max(1, (maxLength - 1) / 2); + return string.Concat(path.AsSpan(0, half), "…", path.AsSpan(path.Length - half)); + } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/RtlConverters.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/RtlConverters.cs new file mode 100644 index 0000000..a29f326 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/RtlConverters.cs @@ -0,0 +1,33 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace ImanSoftware.KeylessAgent.Wpf.Infrastructure; + +/// Converts composer text to a WPF FlowDirection via . +public sealed class RtlFlowDirectionConverter : IValueConverter +{ + /// + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => TextDirection.Detect(value as string) == TextFlowDirection.RightToLeft + ? FlowDirection.RightToLeft + : FlowDirection.LeftToRight; + + /// + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} + +/// Converts composer text to a WPF TextAlignment (Right for RTL, Left for LTR). +public sealed class RtlTextAlignmentConverter : IValueConverter +{ + /// + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => TextDirection.Detect(value as string) == TextFlowDirection.RightToLeft + ? TextAlignment.Right + : TextAlignment.Left; + + /// + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/SettingsService.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/SettingsService.cs new file mode 100644 index 0000000..2f382d6 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/SettingsService.cs @@ -0,0 +1,85 @@ +using System.IO; +using System.Text.Json; + +namespace ImanSoftware.KeylessAgent.Wpf.Infrastructure; + +/// +/// Persists UI-only settings to %APPDATA%\KeylessAgent\wpf-settings.json. +/// A missing or corrupt file yields defaults; the service never throws on load. +/// +public sealed class SettingsService +{ + /// Maximum number of recent workspaces remembered. + public const int MaxRecentWorkspaces = 10; + + /// The settings file name inside the settings folder. + public const string FileName = "wpf-settings.json"; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly string _folder; + + /// + /// Creates the service. Pass a custom folder to redirect persistence (used by tests); + /// the default is %APPDATA%\KeylessAgent. + /// + public SettingsService(string? folder = null) + { + _folder = folder ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "KeylessAgent"); + SettingsFilePath = Path.Combine(_folder, FileName); + } + + /// Full path of the settings file on disk. + public string SettingsFilePath { get; } + + /// Loads the settings; returns defaults when the file is missing or unreadable. + public WpfSettings Load() + { + try + { + if (!File.Exists(SettingsFilePath)) + return new WpfSettings(); + + var loaded = JsonSerializer.Deserialize(File.ReadAllText(SettingsFilePath), SerializerOptions); + return loaded ?? new WpfSettings(); + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + return new WpfSettings(); + } + } + + /// Writes the settings, creating the settings folder when needed. + public void Save(WpfSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + Directory.CreateDirectory(_folder); + File.WriteAllText(SettingsFilePath, JsonSerializer.Serialize(settings, SerializerOptions)); + } + + /// + /// Returns updated settings with the workspace remembered as most recent: the previous + /// entry is removed, the new one is inserted at the front, and the list is truncated to + /// . Empty paths are ignored. + /// + public static WpfSettings WithRecentWorkspace(WpfSettings settings, string workspace) + { + ArgumentNullException.ThrowIfNull(settings); + if (string.IsNullOrWhiteSpace(workspace)) + return settings; + + var trimmed = workspace.Trim(); + var recent = settings.RecentWorkspaces.Where(p => !string.Equals(p, trimmed, StringComparison.OrdinalIgnoreCase)).ToList(); + recent.Insert(0, trimmed); + + return settings with + { + LastWorkspace = trimmed, + RecentWorkspaces = recent.Take(MaxRecentWorkspaces).ToList(), + }; + } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/TextDirection.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/TextDirection.cs new file mode 100644 index 0000000..6760444 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/TextDirection.cs @@ -0,0 +1,41 @@ +namespace ImanSoftware.KeylessAgent.Wpf.Infrastructure; + +/// The writing direction of a piece of text. +public enum TextFlowDirection +{ + /// Left-to-right (default). + LeftToRight = 0, + + /// Right-to-left (Arabic/Persian script). + RightToLeft = 1, +} + +/// +/// Pure text-direction detection for the composer: RightToLeft when the first strong letter is +/// in the Arabic/Persian Unicode ranges (U+0600–U+06FF, U+0750–U+077F, U+FB50–U+FDFF, +/// U+FE70–U+FEFF), LeftToRight otherwise. Digits, spaces and punctuation are neutral. +/// +public static class TextDirection +{ + /// Detects the direction of the text; empty, null and neutral-only text are LeftToRight. + /// The text to inspect; may be null. + public static TextFlowDirection Detect(string? text) + { + foreach (var character in text ?? string.Empty) + { + if (IsArabicScript(character)) + return TextFlowDirection.RightToLeft; + + if (char.IsLetter(character)) + return TextFlowDirection.LeftToRight; + } + + return TextFlowDirection.LeftToRight; + } + + private static bool IsArabicScript(char character) + => character is (>= '\u0600' and <= '\u06FF') + or (>= '\u0750' and <= '\u077F') + or (>= '\uFB50' and <= '\uFDFF') + or (>= '\uFE70' and <= '\uFEFF'); +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/WindowBackdrop.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/WindowBackdrop.cs new file mode 100644 index 0000000..a7c6ec0 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/WindowBackdrop.cs @@ -0,0 +1,49 @@ +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; + +namespace ImanSoftware.KeylessAgent.Wpf.Infrastructure; + +/// +/// Applies the Windows 11 system backdrop and dark title bar to a window. +/// Never throws: on unsupported systems it reports false and the caller shows the +/// app-drawn fallback gradient. +/// +internal static class WindowBackdrop +{ + private const int DwmwaUseImmersiveDarkMode = 20; + private const int DwmwaWindowCornerPreference = 33; + private const int DwmwaSystemBackdropType = 38; // Windows 11 22H2 (build 22621) or newer + private const int RoundCorners = 2; + + [DllImport("dwmapi.dll")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size); + + /// kind: 1 = none, 2 = Mica, 3 = Acrylic. Returns false when it could not be applied. + public static bool TryApply(Window window, int kind) + { + try + { + var handle = new WindowInteropHelper(window).Handle; + if (handle == IntPtr.Zero) return false; + + // Let the DWM draw behind the WPF content. + var source = HwndSource.FromHwnd(handle); + if (source?.CompositionTarget is not null) + source.CompositionTarget.BackgroundColor = Colors.Transparent; + + var dark = 1; + _ = DwmSetWindowAttribute(handle, DwmwaUseImmersiveDarkMode, ref dark, sizeof(int)); + var corners = RoundCorners; + _ = DwmSetWindowAttribute(handle, DwmwaWindowCornerPreference, ref corners, sizeof(int)); + var backdrop = kind; + return DwmSetWindowAttribute(handle, DwmwaSystemBackdropType, ref backdrop, sizeof(int)) == 0; + } + catch (Exception) + { + return false; // the fallback gradient will be shown + } + } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/WindowPlacement.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/WindowPlacement.cs new file mode 100644 index 0000000..0dc12f6 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/WindowPlacement.cs @@ -0,0 +1,71 @@ +namespace ImanSoftware.KeylessAgent.Wpf.Infrastructure; + +/// A validated window placement ready to apply. +/// The clamped left edge. +/// The clamped top edge. +/// The clamped width. +/// The clamped height. +/// False when the saved bounds were unusable (keep the XAML defaults). +public sealed record RestoredWindowPlacement(double Left, double Top, double Width, double Height, bool Restored); + +/// +/// Pure validation of saved window bounds: restores only usable sizes and clamps the position +/// so at least a visible strip of the window stays on a monitor. No WPF types — unit tested +/// with virtual-screen coordinates passed in. +/// +public static class WindowPlacement +{ + /// Sizes below this are treated as unusable. + public const double MinimumSize = 200; + + /// How much of the window must stay visible on a monitor. + public const double MinimumVisible = 100; + + /// Validates saved bounds against the virtual screen. + /// Saved left edge (null = not saved). + /// Saved top edge (null = not saved). + /// Saved width (null = not saved). + /// Saved height (null = not saved). + /// Virtual screen left edge. + /// Virtual screen top edge. + /// Virtual screen right edge. + /// Virtual screen bottom edge. + public static RestoredWindowPlacement Validate( + double? left, double? top, double? width, double? height, + double screenLeft, double screenTop, double screenRight, double screenBottom) + { + if (width is not double savedWidth || height is not double savedHeight + || savedWidth < MinimumSize || savedHeight < MinimumSize + || left is not double savedLeft || top is not double savedTop) + return new RestoredWindowPlacement(0, 0, 0, 0, Restored: false); + + var clampedWidth = Math.Min(savedWidth, Math.Max(0, screenRight - screenLeft)); + var clampedHeight = Math.Min(savedHeight, Math.Max(0, screenBottom - screenTop)); + var visibleX = Math.Min(MinimumVisible, clampedWidth); + var visibleY = Math.Min(MinimumVisible, clampedHeight); + + var minLeft = screenLeft - (clampedWidth - visibleX); + var maxLeft = screenRight - visibleX; + var minTop = screenTop - (clampedHeight - visibleY); + var maxTop = screenBottom - visibleY; + + if (maxLeft < minLeft) + { + var centered = screenLeft + ((screenRight - screenLeft) - clampedWidth) / 2; + minLeft = maxLeft = centered; + } + + if (maxTop < minTop) + { + var centered = screenTop + ((screenBottom - screenTop) - clampedHeight) / 2; + minTop = maxTop = centered; + } + + return new RestoredWindowPlacement( + Math.Clamp(savedLeft, minLeft, maxLeft), + Math.Clamp(savedTop, minTop, maxTop), + clampedWidth, + clampedHeight, + Restored: true); + } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/WpfDispatcher.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/WpfDispatcher.cs new file mode 100644 index 0000000..68d56fa --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/WpfDispatcher.cs @@ -0,0 +1,42 @@ +using System.Windows.Threading; + +namespace ImanSoftware.KeylessAgent.Wpf.Infrastructure; + +/// +/// Production backed by the WPF of the thread +/// that created it (the UI thread, when constructed at application start-up). +/// +public sealed class WpfDispatcher : IUiDispatcher +{ + private readonly Dispatcher _dispatcher; + + /// + /// Creates a dispatcher bound to the calling thread's WPF dispatcher. Construct on the UI thread. + /// + public WpfDispatcher() + : this(Dispatcher.CurrentDispatcher) + { + } + + /// + /// Creates a dispatcher bound to an explicit WPF dispatcher; used by tests. + /// + internal WpfDispatcher(Dispatcher dispatcher) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + /// + public void Post(Action action) + { + ArgumentNullException.ThrowIfNull(action); + _ = _dispatcher.BeginInvoke(action); + } + + /// + public Task InvokeAsync(Func func) + { + ArgumentNullException.ThrowIfNull(func); + return _dispatcher.InvokeAsync(func).Task; + } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/WpfSettings.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/WpfSettings.cs new file mode 100644 index 0000000..7c63164 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Infrastructure/WpfSettings.cs @@ -0,0 +1,48 @@ +namespace ImanSoftware.KeylessAgent.Wpf.Infrastructure; + +/// +/// UI-only settings persisted by to +/// %APPDATA%\KeylessAgent\wpf-settings.json. Property names are the JSON keys. +/// +public sealed record WpfSettings +{ + /// The workspace folder used in the last session; empty on first run. + public string LastWorkspace { get; init; } = string.Empty; + + /// Most recently used workspace folders, newest first; at most 10 entries. + public IReadOnlyList RecentWorkspaces { get; init; } = []; + + /// Disables backdrop spheres and entrance animations on weak machines. + public bool ReduceEffects { get; init; } + + /// Requested system backdrop: Auto, Mica, Acrylic or None. + public string BackdropPreference { get; init; } = "Auto"; + + /// Pre-ticks the dry-run checkbox on the agent page. + public bool DryRunDefault { get; init; } + + /// Restored window placement from the last session. + public WindowSettings Window { get; init; } = new(); +} + +/// +/// The saved window placement. Nullable coordinates mean "no saved value" and are skipped +/// in the JSON file. +/// +public sealed record WindowSettings +{ + /// Restored window left edge in DIPs; null keeps the system default. + public double? Left { get; init; } + + /// Restored window top edge in DIPs; null keeps the system default. + public double? Top { get; init; } + + /// Restored window width in DIPs; null keeps the system default. + public double? Width { get; init; } + + /// Restored window height in DIPs; null keeps the system default. + public double? Height { get; init; } + + /// Whether the window was maximized when the last session ended. + public bool Maximized { get; init; } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Mvvm/AsyncRelayCommand.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Mvvm/AsyncRelayCommand.cs new file mode 100644 index 0000000..6dbf502 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Mvvm/AsyncRelayCommand.cs @@ -0,0 +1,81 @@ +using System.Windows.Input; + +namespace ImanSoftware.KeylessAgent.Wpf.Mvvm; + +/// +/// An asynchronous ICommand that disables itself while running, exposes the running state, +/// catches exceptions and forwards them to an error callback — never throwing on the UI +/// thread. If an operation is already running, Execute is ignored. +/// +public sealed class AsyncRelayCommand : ICommand +{ + private readonly Func _execute; + private readonly Func? _canExecute; + private readonly Action _onError; + private bool _isRunning; + + /// Raised when IsRunning flips (for spinners and disabled states). + public event EventHandler? IsRunningChanged; + + /// True while the async operation is in flight. + public bool IsRunning => _isRunning; + + /// Creates an async command with an optional can-execute predicate. + /// The asynchronous operation. + /// Optional predicate re-checked before each execution. + /// Receives every exception escaping the operation (never rethrown). + public AsyncRelayCommand(Func execute, Func? canExecute = null, Action? onError = null) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute; + _onError = onError ?? (_ => { }); + } + + /// + public event EventHandler? CanExecuteChanged; + + /// Raises CanExecuteChanged so bound UI re-evaluates the command state. + public void NotifyCanExecuteChanged() + => CanExecuteChanged?.Invoke(this, EventArgs.Empty); + + /// + public bool CanExecute(object? parameter) + => !_isRunning && (_canExecute?.Invoke() ?? true); + + /// + public async void Execute(object? parameter) + { + if (_isRunning || !CanExecute(parameter)) + return; + + _isRunning = true; + NotifyCanExecuteChanged(); + IsRunningChanged?.Invoke(this, new IsRunningChangedEventArgs(true)); + try + { + await _execute(); + } + catch (Exception ex) + { + _onError(ex); + } + finally + { + _isRunning = false; + IsRunningChanged?.Invoke(this, new IsRunningChangedEventArgs(false)); + NotifyCanExecuteChanged(); + } + } + +} + +/// Event data for an flip. +public sealed class IsRunningChangedEventArgs : EventArgs +{ + /// Initializes the event data. + /// Whether the command started running. + public IsRunningChangedEventArgs(bool isRunning) => IsRunning = isRunning; + + /// Whether the command is running. + public bool IsRunning { get; } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Mvvm/ObservableObject.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Mvvm/ObservableObject.cs new file mode 100644 index 0000000..1063bfb --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Mvvm/ObservableObject.cs @@ -0,0 +1,33 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace ImanSoftware.KeylessAgent.Wpf.Mvvm; + +/// +/// Minimal INotifyPropertyChanged base for ViewModels: raises change notifications on the +/// calling thread (WPF marshals binding updates itself) and supports caller-name inference. +/// +public abstract class ObservableObject : INotifyPropertyChanged +{ + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// Raises for the named property. + /// Defaults to the calling member's name. + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + + /// + /// Compares old and new values and, when different, sets the field and raises + /// . Returns true when the value changed. + /// + protected bool SetProperty(ref T field, T newValue, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, newValue)) + return false; + + field = newValue; + OnPropertyChanged(propertyName); + return true; + } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Mvvm/RelayCommand.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Mvvm/RelayCommand.cs new file mode 100644 index 0000000..ca12584 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Mvvm/RelayCommand.cs @@ -0,0 +1,39 @@ +using System.Windows.Input; + +namespace ImanSoftware.KeylessAgent.Wpf.Mvvm; + +/// +/// A synchronous ICommand that forwards to a delegate. CanExecute can be re-evaluated +/// explicitly by ViewModels (via CommandManager or direct NotifyCanExecuteChanged). +/// +public sealed class RelayCommand : ICommand +{ + private readonly Action _execute; + private readonly Func? _canExecute; + + /// Creates a command that always can execute. + public RelayCommand(Action execute) + : this(execute, canExecute: null) + { + } + + /// Creates a command with an optional can-execute predicate. + public RelayCommand(Action execute, Func? canExecute) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute; + } + + /// + public event EventHandler? CanExecuteChanged; + + /// Raises CanExecuteChanged so bound UI re-evaluates the command state. + public void NotifyCanExecuteChanged() + => CanExecuteChanged?.Invoke(this, EventArgs.Empty); + + /// + public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true; + + /// + public void Execute(object? parameter) => _execute(); +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Mvvm/RelayCommandOfT.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Mvvm/RelayCommandOfT.cs new file mode 100644 index 0000000..0349d17 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Mvvm/RelayCommandOfT.cs @@ -0,0 +1,39 @@ +using System.Windows.Input; + +namespace ImanSoftware.KeylessAgent.Wpf.Mvvm; + +/// +/// A command with a typed parameter. The parameter arrives as object from WPF binding +/// and is cast; null is passed through as null. +/// +/// The parameter type. +public sealed class RelayCommand : ICommand +{ + private readonly Action _execute; + private readonly Func? _canExecute; + + /// + /// Creates the command. + /// + /// The action to run; must not be null. + /// Optional gate evaluated on every call. + public RelayCommand(Action execute, Func? canExecute = null) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute; + } + + /// + public event EventHandler? CanExecuteChanged; + + /// Raises so bindings re-query the gate. + public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); + + /// + public bool CanExecute(object? parameter) => _canExecute?.Invoke(Cast(parameter)) ?? true; + + /// + public void Execute(object? parameter) => _execute(Cast(parameter)); + + private static T? Cast(object? parameter) => parameter is T typed ? typed : default; +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Properties/PublishProfiles/FolderProfile.pubxml b/src/UI/ImanSoftware.KeylessAgent.Wpf/Properties/PublishProfiles/FolderProfile.pubxml new file mode 100644 index 0000000..e0991a3 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Properties/PublishProfiles/FolderProfile.pubxml @@ -0,0 +1,16 @@ + + + + + Release + Any CPU + bin\Release\net10.0-windows\publish\win-x64\ + FileSystem + <_TargetId>Folder + net10.0-windows + win-x64 + true + true + false + + \ No newline at end of file diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/README.md b/src/UI/ImanSoftware.KeylessAgent.Wpf/README.md new file mode 100644 index 0000000..8a6aa1a --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/README.md @@ -0,0 +1,45 @@ +# KeylessAgent WPF + +**Desktop front end for KeylessAgent — a coding agent that runs on your own DeepSeek login. No API key.** + +The WPF app wraps the same engine as the [CLI](../ImanSoftware.KeylessAgent.Cli/README.md) in a Midnight & Champagne glass UI: pick a workspace, watch the turn stream live (commands, diffs, results), and control the hidden Chrome window without touching a terminal. + +> Automating a web UI may violate the provider's terms of use and can get your account limited. This project is not affiliated with DeepSeek. + +## Highlights + +- **Agent page** — request box, live turn feed (thinking, commands, diffs, results), pacing display, cancel support. Every file a turn touches is backed up and shown as a diff card. +- **Doctor page** — Start Chrome, Show Chrome, Hide Chrome, Close Chrome buttons plus the live selector check (MATCHED / NOT FOUND / NOT APPLICABLE per selector) with the same diagnostics as `keyless doctor`. +- **Settings page** — edit `keyless.json` (approval mode, pacing, browser behavior, snapshot budgets, filters) with validation; changes are written back through the same tolerant loader the CLI uses. +- **Workspace page** — pick the workspace, inspect the environment (git, dotnet, node…), switch branches safely. +- **Undo** — restore the newest backup batch from `.agent/backups`. + +## Chrome window control + +The agent's Chrome window is invisible by design (a tiny corner square that keeps the renderer unthrottled). The Doctor page gives you: + +| Button | What it does | +|---|---| +| Start Chrome | Starts/attaches the dedicated profile and shows the window (one-time DeepSeek login) | +| Show Chrome | Brings the window to (100,100) 1280×900 whenever you need it | +| Hide Chrome | Returns it to the imperceptible corner square | +| Close Chrome | Gracefully closes the whole browser (CDP `Browser.close`, then a process-tree kill fallback) — refused for a Chrome you launched yourself | + +On normal application exit the app closes the Chrome it launched automatically (bounded to a few seconds; a Chrome you attached to manually is never touched). + +## Requirements + +- Windows 10/11, .NET 10 Desktop Runtime, Git, Chrome. +- Run `keyless chrome` (or Start Chrome) once and log in to DeepSeek in the dedicated profile. + +## Build + +```bash +dotnet build src/UI/ImanSoftware.KeylessAgent.Wpf -c Release +``` + +## License + +MIT — see [LICENSE](https://github.com/ImanEstiri/KeylessAgent/blob/main/LICENSE.txt). + +Full documentation — AgentScript, configuration, selectors, reply sources, security model — lives in the main repository: **[github.com/ImanEstiri/KeylessAgent](https://github.com/ImanEstiri/KeylessAgent)**. diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/AgentLoopFactory.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/AgentLoopFactory.cs new file mode 100644 index 0000000..19daa59 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/AgentLoopFactory.cs @@ -0,0 +1,18 @@ +using ImanSoftware.KeylessAgent.Abstractions.Services; +using ImanSoftware.KeylessAgent.Core.Factory; +using Microsoft.Extensions.Logging; + +namespace ImanSoftware.KeylessAgent.Wpf.Services; + +/// +/// Creates the real agent loop through the Core service factory — the same construction path +/// the CLI uses. Tests never touch this class; they inject fakes into . +/// +internal static class AgentLoopFactory +{ + /// Creates an agent loop for a workspace. + /// Workspace directory; also the log location when verbose. + /// Enables the file logger (.agent/logs/diagnostics.log). + public static IAgentLoop Create(string workspaceRoot, bool verbose) + => KeylessAgentServiceFactory.CreateAgentLoop(verbose ? KeylessAgentServiceFactory.CreateFileLoggerFactory(workspaceRoot) : null); +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/AgentRunner.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/AgentRunner.cs new file mode 100644 index 0000000..dabf95a --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/AgentRunner.cs @@ -0,0 +1,85 @@ +using ImanSoftware.KeylessAgent.Abstractions.Services; +using ImanSoftware.KeylessAgent.Wpf.Infrastructure; +using ImanSoftware.KeylessAgent.Wpf.ViewModels; +using ImanSoftware.KeylessAgent.Wpf.ViewModels.Feed; + +namespace ImanSoftware.KeylessAgent.Wpf.Services; + +/// +/// The only place that calls the agent loop. Creates the loop through the injected factory, +/// runs the turn on a fresh , maps the result to a +/// , and turns cancellation and exceptions into feed errors — +/// the app never crashes because of a failing turn. +/// +public sealed class AgentRunner +{ + private readonly IUiDispatcher _ui; + private readonly ActivityFeed _feed; + private readonly Func _uiFactory; + private readonly Func _loopFactory; + + /// Creates the turn runner. + /// Dispatcher for posting feed updates onto the UI thread. + /// The shared activity feed the turn writes into. + /// Creates the IAgentUserInterface for the turn (real or fake). + /// Creates the loop for a workspace: (workspaceRoot, verbose) → loop. + public AgentRunner(IUiDispatcher ui, ActivityFeed feed, Func uiFactory, Func loopFactory) + { + _ui = ui ?? throw new ArgumentNullException(nameof(ui)); + _feed = feed ?? throw new ArgumentNullException(nameof(feed)); + _uiFactory = uiFactory ?? throw new ArgumentNullException(nameof(uiFactory)); + _loopFactory = loopFactory ?? throw new ArgumentNullException(nameof(loopFactory)); + } + + /// Runs one turn and posts the summary (or the error/cancellation) to the feed. + /// The workspace directory. + /// The user's request. + /// Dry-run mode (nothing is executed). + /// Verbose diagnostics logging. + /// Cancels the turn. + /// The mapped summary; null when the turn failed, was cancelled, or ended with an error. + public async Task RunTurnAsync( + string workspaceRoot, string request, bool dryRun, bool verbose, CancellationToken cancellationToken, + ImanSoftware.KeylessAgent.Abstractions.Models.Enums.AgentPace? paceOverride = null) + { + var ui = _uiFactory(); + try + { + var loop = _loopFactory(workspaceRoot, verbose); + var result = await loop.RunAsync(workspaceRoot, request, ui, dryRun, cancellationToken, paceOverride: paceOverride); + + if (result.ErrorMessage is not null) + { + PostError(result.ErrorMessage); + return null; + } + + var summary = new TurnSummaryItem( + result.Turn, + result.Branch, + [.. result.Commits], + result.Summary, + result.CompletedByDone, + result.StoppedReason, + result.DryRun, + result.ChangeSet, + result.AutoApprovedCommands, + result.ConfirmedCommands, + result.DiffStat); + _ui.Post(() => _feed.AddSummary(summary)); + return summary; + } + catch (OperationCanceledException) + { + PostError("Cancelled"); + return null; + } + catch (Exception ex) + { + PostError($"{ex.GetType().Name}: {ex.Message}"); + return null; + } + } + + private void PostError(string message) => _ui.Post(() => _feed.AddError(message)); +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/DialogHost.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/DialogHost.cs new file mode 100644 index 0000000..cf9a105 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/DialogHost.cs @@ -0,0 +1,51 @@ +using ImanSoftware.KeylessAgent.Wpf.ViewModels; + +namespace ImanSoftware.KeylessAgent.Wpf.Services; + +/// +/// Static accessor for the dialog infrastructure so any component (e.g. the agent Stop button +/// in a later phase) can cancel pending dialogs without wiring references through every +/// view model. Initialized once at window construction. +/// +public static class DialogHost +{ + private static DialogHostViewModel? _host; + private static IDialogService? _dialogs; + + /// The view model of the currently shown dialog; null before initialization or when no dialog is open. + public static object? Current => _host?.Current; + + /// + /// Registers the shared dialog host and service. Call once at application start-up + /// (later calls replace the previous registrations). + /// + /// The shell's dialog host view model. + /// The dialog service that shows dialogs in it. + public static void Initialize(DialogHostViewModel host, IDialogService dialogs) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _dialogs = dialogs ?? throw new ArgumentNullException(nameof(dialogs)); + } + + /// + /// Completes every pending dialog as cancelled (confirm → false, choice → cancelled, + /// ask → empty). Called when the user stops a running turn. + /// + public static void CancelAll() => _dialogs?.CancelAll(); + + /// Whether an error dialog is currently shown (guards against error floods). + public static bool HasError => _host?.Current is ViewModels.Dialogs.ErrorDialogViewModel; + + /// + /// Shows a short error through the dialog host, replacing whatever was shown. Used by the + /// global exception handler; does nothing before initialization. + /// + /// The short error message. + public static void ShowError(string message) + { + if (_host is null) + return; + + _host.Show(new ViewModels.Dialogs.ErrorDialogViewModel("Unexpected error", message, () => _host.Hide())); + } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/DialogService.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/DialogService.cs new file mode 100644 index 0000000..c63c488 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/DialogService.cs @@ -0,0 +1,165 @@ +using ImanSoftware.KeylessAgent.Abstractions.Models.Agent; +using ImanSoftware.KeylessAgent.Wpf.Infrastructure; +using ImanSoftware.KeylessAgent.Wpf.ViewModels; + +namespace ImanSoftware.KeylessAgent.Wpf.Services; + +/// +/// The queued modal dialog service. A SemaphoreSlim(1,1) serializes dialogs; every +/// request creates its completion source up front so can complete even +/// requests that are still queued behind another dialog. The gate is always released in +/// finally, the host is always hidden after a dialog ends. +/// +public sealed class DialogService : IDialogService, IDisposable +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly DialogHostViewModel _host; + private readonly IUiDispatcher _ui; + private readonly object _pendingLock = new(); + private readonly List _pending = []; + + /// Creates the dialog service. + /// The shell's dialog host view model (overlay visibility). + /// Dispatcher used to show/hide the overlay on the UI thread. + public DialogService(DialogHostViewModel host, IUiDispatcher ui) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _ui = ui ?? throw new ArgumentNullException(nameof(ui)); + } + + /// + public async Task ConfirmAsync(ConfirmRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var tcs = NewSource(); + var pending = Register(() => tcs.TrySetResult(false)); + + await _gate.WaitAsync(); + try + { + if (!tcs.Task.IsCompleted) + { + var viewModel = new ViewModels.Dialogs.ConfirmDialogViewModel( + request, approved => Complete(pending, () => tcs.TrySetResult(approved))); + Show(viewModel); + } + + return await tcs.Task; + } + finally + { + HideAndRelease(); + } + } + + /// + public async Task AskAsync(string question) + { + var tcs = NewSource(); + var pending = Register(() => tcs.TrySetResult(string.Empty)); + + await _gate.WaitAsync(); + try + { + if (!tcs.Task.IsCompleted) + { + var viewModel = new ViewModels.Dialogs.AskDialogViewModel( + question, answer => Complete(pending, () => tcs.TrySetResult(answer))); + Show(viewModel); + } + + return await tcs.Task; + } + finally + { + HideAndRelease(); + } + } + + /// + public async Task ChooseAsync(ChoiceRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var tcs = NewSource(); + var pending = Register(() => tcs.TrySetResult(ChoiceResult.CancelledMenu())); + using var registration = cancellationToken.Register( + () => Complete(pending, () => tcs.TrySetResult(ChoiceResult.CancelledMenu()))); + + await _gate.WaitAsync(CancellationToken.None); + try + { + if (!tcs.Task.IsCompleted) + { + var viewModel = new ViewModels.Dialogs.ChoiceDialogViewModel( + request, result => Complete(pending, () => tcs.TrySetResult(result))); + Show(viewModel); + } + + return await tcs.Task; + } + finally + { + HideAndRelease(); + } + } + + /// + public void CancelAll() + { + List snapshot; + lock (_pendingLock) + { + snapshot = [.. _pending]; + _pending.Clear(); + } + + if (snapshot.Count == 0) + return; + + foreach (var pending in snapshot) + pending.OnCancel(); + + Hide(); + } + + private static TaskCompletionSource NewSource() + => new(TaskCreationOptions.RunContinuationsAsynchronously); + + private PendingDialog Register(Action onCancel) + { + var pending = new PendingDialog(onCancel); + lock (_pendingLock) + _pending.Add(pending); + return pending; + } + + private void Complete(PendingDialog pending, Action setResult) + { + setResult(); + Unregister(pending); + } + + private void Unregister(PendingDialog pending) + { + lock (_pendingLock) + _ = _pending.Remove(pending); + } + + private void Show(object viewModel) => _ui.Post(() => _host.Show(viewModel)); + + private void Hide() => _ui.Post(_host.Hide); + + private void HideAndRelease() + { + Hide(); + _gate.Release(); + } + + /// Releases the internal gate semaphore. Only call when no dialog requests are in flight. + public void Dispose() => _gate.Dispose(); + + private sealed class PendingDialog(Action onCancel) + { + public Action OnCancel { get; } = onCancel; + } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/DoctorRowMapper.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/DoctorRowMapper.cs new file mode 100644 index 0000000..a23f8db --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/DoctorRowMapper.cs @@ -0,0 +1,72 @@ +using ImanSoftware.KeylessAgent.Abstractions.Models.Enums; +using ImanSoftware.KeylessAgent.Abstractions.Services; + +namespace ImanSoftware.KeylessAgent.Wpf.Services; + +/// One selector check row of the doctor report table. +/// The selectors.json key. +/// The raw check status (drives the chip color). +/// The display text (MATCHED / NOT FOUND / NOT APPLICABLE). +/// "#N" of the matching fallback or "-". +/// The matched element count or "-". +/// The human-readable detail text. +public sealed record DoctorCheckRow( + string Key, + SelectorCheckStatus Status, + string State, + string Fallback, + string Elements, + string Detail); + +/// One summary row above the doctor table (Logged in, toggles, input, all-matched). +/// What was probed. +/// The display value. +/// Whether the value is healthy (chip color). +public sealed record DoctorSummaryRow(string Label, string Value, bool Ok); + +/// +/// Pure mapping from a to the doctor page rows (the CLI +/// DoctorCommand table, UI-flavored). No WPF types — unit tested directly. +/// +public static class DoctorRowMapper +{ + /// Maps every selector check to a table row. + /// The checks of the report. + public static IReadOnlyList MapChecks(IReadOnlyList checks) + { + ArgumentNullException.ThrowIfNull(checks); + return checks.Select(MapCheck).ToList(); + } + + /// Maps the report's summary values (order matches the CLI output). + /// The doctor report. + public static IReadOnlyList MapSummary(ChatDiagnosticsReport report) + { + ArgumentNullException.ThrowIfNull(report); + return + [ + new DoctorSummaryRow("Logged in", report.LoggedIn ? "yes" : "NO — the login page is shown", report.LoggedIn), + new DoctorSummaryRow("DeepThink pressed", report.DeepThinkPressed ?? "not found", report.DeepThinkPressed == "true"), + new DoctorSummaryRow("Search pressed", report.SearchPressed ?? "not found", report.SearchPressed == "true"), + new DoctorSummaryRow("Input accepts text", report.InputAcceptsText ? "yes" : "NO", report.InputAcceptsText), + new DoctorSummaryRow("All required matched", report.AllRequiredMatched ? "yes" : "NO", report.AllRequiredMatched), + ]; + } + + private static DoctorCheckRow MapCheck(SelectorCheck check) => new( + check.Key, + check.Status, + check.Status switch + { + SelectorCheckStatus.Matched => "MATCHED", + SelectorCheckStatus.NotFound => "NOT FOUND", + _ => "NOT APPLICABLE NOW", + }, + check.Status == SelectorCheckStatus.Matched + ? string.Create(System.Globalization.CultureInfo.InvariantCulture, $"#{check.FallbackIndex}") + : "-", + check.Status == SelectorCheckStatus.Matched + ? check.MatchCount.ToString(System.Globalization.CultureInfo.InvariantCulture) + : "-", + check.Detail); +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/EventPresenter.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/EventPresenter.cs new file mode 100644 index 0000000..1dc6918 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/EventPresenter.cs @@ -0,0 +1,82 @@ +using System.Text.RegularExpressions; +using ImanSoftware.KeylessAgent.Abstractions.Models.Agent; +using ImanSoftware.KeylessAgent.Abstractions.Models.Enums; + +namespace ImanSoftware.KeylessAgent.Wpf.Services; + +/// +/// Visual weight of a progress event in the feed: a normal step, a waiting state (pulsing), +/// a successful completion, or a warning. +/// +public enum EventKind +{ + /// A normal progress step. + Step = 0, + + /// A wait state (pulsing dot): reply wait, user question. + Waiting = 1, + + /// A successful completion (check mark). + Success = 2, + + /// A warning (resend request). + Warning = 3, +} + +/// The presentation of one progress event: label, symbol and kind. +public sealed record EventPresentation(string Label, string Symbol, EventKind Kind); + +/// +/// Pure mapping from to feed presentation (the Phase 6 table of +/// the guide) plus the #N Kind Target detail parser for executed commands. +/// +public static class EventPresenter +{ + /// Matches details like "#1 RunCommand mkdir y" reported for ExecutingCommand events. + private static readonly Regex CommandDetail = new( + "^#(?[0-9]+)[ ]+(?[A-Za-z_][A-Za-z0-9_]*)[ ]+(?.+)$", + RegexOptions.Compiled, + TimeSpan.FromSeconds(1)); + + /// Maps a progress event to its feed presentation. Unknown event types never crash. + /// The event reported by Core. + public static EventPresentation Present(AgentProgressEvent progressEvent) + { + ArgumentNullException.ThrowIfNull(progressEvent); + return progressEvent.Type switch + { + AgentEventType.SessionStarting => new EventPresentation("Preparing the session", "●", EventKind.Step), + AgentEventType.ConnectingToBrowser => new EventPresentation("Connecting to Chrome", "●", EventKind.Step), + AgentEventType.OpeningChat => new EventPresentation("Opening the chat", "●", EventKind.Step), + AgentEventType.UploadingAttachments => new EventPresentation("Uploading project snapshot", "▲", EventKind.Step), + AgentEventType.SendingMessage => new EventPresentation("Sending your request", "▶", EventKind.Step), + AgentEventType.WaitingForReply => new EventPresentation("Waiting for the model", "●", EventKind.Waiting), + AgentEventType.ValidatingReply => new EventPresentation("Checking the reply", "✓", EventKind.Step), + AgentEventType.RequestingResend => new EventPresentation("Asking the model to fix its reply", "⚠", EventKind.Warning), + AgentEventType.ExecutingCommand => new EventPresentation("Running a command", "▶", EventKind.Step), + AgentEventType.AskingUser => new EventPresentation("Waiting for you", "●", EventKind.Waiting), + AgentEventType.SendingResults => new EventPresentation("Sending results to the model", "▶", EventKind.Step), + AgentEventType.RefreshingSnapshot => new EventPresentation("Updating the project snapshot", "●", EventKind.Step), + AgentEventType.TurnFinished => new EventPresentation("Finished", "✓", EventKind.Success), + _ => new EventPresentation(progressEvent.Type.ToString(), "●", EventKind.Step), + }; + } + + /// + /// Parses the ExecutingCommand detail "#1 RunCommand mkdir y" into number, kind and target. + /// Returns null when the text does not match (the raw text is shown instead). + /// + /// The progress detail text; may be null. + public static (int Number, string Kind, string Target)? TryParseCommandDetail(string? detail) + { + if (detail is null) + return null; + + var match = CommandDetail.Match(detail); + if (!match.Success) + return null; + + var number = int.Parse(match.Groups["Number"].Value, System.Globalization.CultureInfo.InvariantCulture); + return (number, match.Groups["Kind"].Value, match.Groups["Target"].Value.Trim()); + } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/IDialogService.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/IDialogService.cs new file mode 100644 index 0000000..791ee34 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/IDialogService.cs @@ -0,0 +1,30 @@ +using ImanSoftware.KeylessAgent.Abstractions.Models.Agent; + +namespace ImanSoftware.KeylessAgent.Wpf.Services; + +/// +/// Shows modal dialogs inside the shell overlay. One dialog at a time — further requests queue +/// until the current one is answered. completes every pending request +/// (confirm → false, choice → cancelled, ask → empty) so a stopped turn cannot hang the agent. +/// +public interface IDialogService +{ + /// Shows a yes/no dialog and completes with the user's decision. + /// Title and detail text to display. + /// True when the user approved. + Task ConfirmAsync(ConfirmRequest request); + + /// Shows a multiline text-input dialog and completes with the answer. + /// The question shown above the input box. + /// The user's answer; empty when cancelled. + Task AskAsync(string question); + + /// Shows a numbered choice menu and completes with the selection. + /// Title, options and default option. + /// Completes the menu as cancelled when triggered. + /// The selection result. + Task ChooseAsync(ChoiceRequest request, CancellationToken cancellationToken = default); + + /// Completes every pending dialog as if the user cancelled it. + void CancelAll(); +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/LineDiff.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/LineDiff.cs new file mode 100644 index 0000000..21ae61c --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/LineDiff.cs @@ -0,0 +1,167 @@ +namespace ImanSoftware.KeylessAgent.Wpf.Services; + +/// The kind of one diff row. +internal enum DiffLineKind +{ + /// Present in old and new content (dimmed). + Unchanged = 0, + + /// Only in the new content (green tint). + Added = 1, + + /// Only in the old content (red tint). + Removed = 2, +} + +/// One row of the unified diff: text, kind and the 1-based old/new line numbers when present. +internal sealed record DiffLine(DiffLineKind Kind, string Text, int? OldNumber, int? NewNumber); + +/// The outcome of a line diff. When , no LCS ran: is empty, +/// holds the old file's line count and the new file's line count. +internal sealed record DiffResult(IReadOnlyList Lines, int Added, int Removed, bool TooLarge); + +/// +/// Pure line diff (Appendix D): normalize line endings, too-large fast path, common prefix/suffix +/// trim, LCS dynamic-programming table on the middle part. No WPF types — unit tested directly. +/// +internal static class LineDiff +{ + /// Files with more lines than this are not diffed (TooLarge). + public const int MaxLines = 3000; + + /// Upper bound for the LCS table; above it the middle part falls back to all-removed-then-all-added. + private const long MaxLcsCells = 3000L * 3000L; + + /// Compares two texts line by line. CRLF and LF are the same line ending; a trailing + /// newline difference is ignored; null means an empty file. + /// The old content (null for a new file). + /// The new content (null for a deleted file). + public static DiffResult Compare(string? oldText, string? newText) + { + var oldLines = SplitLines(oldText); + var newLines = SplitLines(newText); + + if (oldLines.Length > MaxLines || newLines.Length > MaxLines) + return new DiffResult([], newLines.Length, oldLines.Length, TooLarge: true); + + var lines = new List(oldLines.Length + newLines.Length); + + var prefix = 0; + while (prefix < oldLines.Length && prefix < newLines.Length && oldLines[prefix] == newLines[prefix]) + prefix++; + + var suffix = 0; + while (suffix < oldLines.Length - prefix && suffix < newLines.Length - prefix + && oldLines[oldLines.Length - 1 - suffix] == newLines[newLines.Length - 1 - suffix]) + suffix++; + + for (var i = 0; i < prefix; i++) + lines.Add(new DiffLine(DiffLineKind.Unchanged, oldLines[i], i + 1, i + 1)); + + AppendMiddle(lines, oldLines, newLines, prefix, prefix, suffix); + + var oldSuffixStart = oldLines.Length - suffix; + var newSuffixStart = newLines.Length - suffix; + for (var i = 0; i < suffix; i++) + lines.Add(new DiffLine(DiffLineKind.Unchanged, oldLines[oldSuffixStart + i], oldSuffixStart + i + 1, newSuffixStart + i + 1)); + + var added = 0; + var removed = 0; + foreach (var line in lines) + { + if (line.Kind == DiffLineKind.Added) added++; + else if (line.Kind == DiffLineKind.Removed) removed++; + } + + return new DiffResult(lines, added, removed, TooLarge: false); + } + + /// Splits text into lines per step 1 of Appendix D (line-ending normalization, no trailing empty line). + /// The raw text; null yields an empty array. + internal static string[] SplitLines(string? text) + { + if (string.IsNullOrEmpty(text)) + return []; + + var normalized = text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\r", "\n", StringComparison.Ordinal); + var lines = normalized.Split('\n'); + return lines.Length > 0 && lines[^1].Length == 0 ? lines[..^1] : lines; + } + + private static void AppendMiddle(List lines, string[] oldLines, string[] newLines, int oldStart, int newStart, int suffix) + { + var oldFirst = oldStart; + var oldCount = oldLines.Length - suffix - oldFirst; + var newCount = newLines.Length - suffix - newStart; + + var oldMiddle = new string[oldCount]; + Array.Copy(oldLines, oldFirst, oldMiddle, 0, oldCount); + var newMiddle = new string[newCount]; + Array.Copy(newLines, newStart, newMiddle, 0, newCount); + + if (oldMiddle.Length == 0) + { + for (var j = 0; j < newMiddle.Length; j++) + lines.Add(new DiffLine(DiffLineKind.Added, newMiddle[j], null, newStart + j + 1)); + return; + } + + if (newMiddle.Length == 0) + { + for (var i = 0; i < oldMiddle.Length; i++) + lines.Add(new DiffLine(DiffLineKind.Removed, oldMiddle[i], oldStart + i + 1, null)); + return; + } + + if ((long)oldMiddle.Length * newMiddle.Length > MaxLcsCells) + { + for (var i = 0; i < oldMiddle.Length; i++) + lines.Add(new DiffLine(DiffLineKind.Removed, oldMiddle[i], oldStart + i + 1, null)); + for (var j = 0; j < newMiddle.Length; j++) + lines.Add(new DiffLine(DiffLineKind.Added, newMiddle[j], null, newStart + j + 1)); + return; + } + + var table = new int[oldMiddle.Length + 1][]; + for (var row = 0; row <= oldMiddle.Length; row++) + table[row] = new int[newMiddle.Length + 1]; + + for (var i = oldMiddle.Length - 1; i >= 0; i--) + { + for (var j = newMiddle.Length - 1; j >= 0; j--) + { + table[i][j] = oldMiddle[i] == newMiddle[j] + ? table[i + 1][j + 1] + 1 + : Math.Max(table[i + 1][j], table[i][j + 1]); + } + } + + var x = 0; + var y = 0; + while (x < oldMiddle.Length && y < newMiddle.Length) + { + if (oldMiddle[x] == newMiddle[y]) + { + lines.Add(new DiffLine(DiffLineKind.Unchanged, oldMiddle[x], oldStart + x + 1, newStart + y + 1)); + x++; + y++; + } + else if (table[x + 1][y] >= table[x][y + 1]) + { + lines.Add(new DiffLine(DiffLineKind.Removed, oldMiddle[x], oldStart + x + 1, null)); + x++; + } + else + { + lines.Add(new DiffLine(DiffLineKind.Added, newMiddle[y], null, newStart + y + 1)); + y++; + } + } + + for (; x < oldMiddle.Length; x++) + lines.Add(new DiffLine(DiffLineKind.Removed, oldMiddle[x], oldStart + x + 1, null)); + + for (; y < newMiddle.Length; y++) + lines.Add(new DiffLine(DiffLineKind.Added, newMiddle[y], null, newStart + y + 1)); + } +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/SettingsGroupMapper.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/SettingsGroupMapper.cs new file mode 100644 index 0000000..b5e3cc6 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/SettingsGroupMapper.cs @@ -0,0 +1,80 @@ +using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration; + +namespace ImanSoftware.KeylessAgent.Wpf.Services; + +/// One read-only key/value row of a settings group. +public sealed record SettingRow(string Key, string Value); + +/// A group of read-only settings rows (Mode, Browser, Limits, Snapshot, Git, Commands). +public sealed record SettingsGroup(string Title, IReadOnlyList Rows); + +/// +/// Pure mapping from to the grouped read-only display on the +/// settings page. No WPF types — unit tested directly. +/// +public static class SettingsGroupMapper +{ + /// Maps the loaded options to the grouped display (guide order: Mode, Browser, Limits, Snapshot, Git, Commands). + /// The loaded configuration. + public static IReadOnlyList Map(KeylessOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return + [ + new SettingsGroup("Mode", + [ + new SettingRow("Approval", options.Mode.ToString()), + new SettingRow("Pace", options.Agent.Pace.ToString()), + ]), + new SettingsGroup("Browser", + [ + Row("ChromeDebugPort", options.Browser.ChromeDebugPort), + Row("UserDataDir", options.Browser.UserDataDir.Length == 0 ? "(default profile)" : options.Browser.UserDataDir), + Row("AutoLaunchChrome", options.Browser.AutoLaunchChrome), + Row("ReuseExistingTab", options.Browser.ReuseExistingTab), + Row("MaxTurnsPerChat", options.Browser.MaxTurnsPerChat), + Row("AllowPlaywrightLaunch", options.Browser.AllowPlaywrightLaunch), + Row("CloseChromeOnExit", options.Browser.CloseChromeOnExit), + Row("EnableDeepThink", options.Browser.EnableDeepThink), + Row("EnableSearch", options.Browser.EnableSearch), + Row("ReplySource", options.Browser.ReplySource.ToString()), + Row("WindowMode", options.Browser.WindowMode), + Row("ReplyTimeoutSeconds", options.Browser.ReplyTimeoutSeconds), + Row("SendConfirmSeconds", options.Browser.SendConfirmSeconds), + Row("Debug", options.Browser.Debug), + ]), + new SettingsGroup("Limits", + [ + Row("MaxCommandsPerResponse", options.Limits.MaxCommandsPerResponse), + Row("MaxResponseChars", options.Limits.MaxResponseChars), + Row("MaxFileContentChars", options.Limits.MaxFileContentChars), + Row("MaxCommandOutputChars", options.Limits.MaxCommandOutputChars), + Row("MaxFormatRetries", options.Limits.MaxFormatRetries), + Row("MaxIterationsPerTurn", options.Limits.MaxIterationsPerTurn), + ]), + new SettingsGroup("Snapshot", + [ + Row("Mode", options.Snapshot.Mode.ToString()), + Row("AttachOnFeedback", options.Snapshot.AttachOnFeedback), + Row("RefreshAfterEachBatch", options.Snapshot.RefreshAfterEachBatch), + ]), + new SettingsGroup("Git", + [ + Row("AutoInit", options.Git.AutoInit), + Row("BranchPrefix", options.Git.BranchPrefix), + new SettingRow("ProtectedBranchPatterns", string.Join(", ", options.Git.ProtectedBranchPatterns)), + ]), + new SettingsGroup("Commands", + [ + new SettingRow("Allowlist", string.Join(", ", options.Commands.Allowlist)), + new SettingRow("AutoApprove", string.Join(", ", options.Commands.AutoApprove)), + Row("BatchFailureMode", options.Commands.BatchFailureMode), + Row("TimeoutSeconds", options.Commands.TimeoutSeconds), + ]), + ]; + } + + private static SettingRow Row(string key, object value) + => new(key, System.Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty); +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/WpfUserInterface.cs b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/WpfUserInterface.cs new file mode 100644 index 0000000..bbbd847 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Services/WpfUserInterface.cs @@ -0,0 +1,60 @@ +using ImanSoftware.KeylessAgent.Abstractions.Models.Agent; +using ImanSoftware.KeylessAgent.Abstractions.Services; +using ImanSoftware.KeylessAgent.Wpf.Infrastructure; +using ImanSoftware.KeylessAgent.Wpf.ViewModels; + +namespace ImanSoftware.KeylessAgent.Wpf.Services; + +/// +/// The WPF implementation of . Feed updates are posted to the +/// UI thread so Core can call from any thread; dialogs delegate to ; +/// waits go through the nested stack. +/// +internal sealed class WpfUserInterface : IAgentUserInterface +{ + private readonly IUiDispatcher _ui; + private readonly IDialogService _dialogs; + private readonly ActivityFeed _feed; + private readonly BusyIndicator _busy; + + /// Creates the agent UI. + /// Dispatcher that marshals feed updates onto the UI thread. + /// Queued modal dialog service. + /// The activity feed that receives Say/progress/diff entries. + /// The nested wait stack that receives BeginWait scopes. + public WpfUserInterface(IUiDispatcher ui, IDialogService dialogs, ActivityFeed feed, BusyIndicator busy) + { + _ui = ui ?? throw new ArgumentNullException(nameof(ui)); + _dialogs = dialogs ?? throw new ArgumentNullException(nameof(dialogs)); + _feed = feed ?? throw new ArgumentNullException(nameof(feed)); + _busy = busy ?? throw new ArgumentNullException(nameof(busy)); + } + + /// The feed this UI writes to (bound by the agent page). + public ActivityFeed Feed => _feed; + + /// The busy stack this UI writes to (bound by the status bar). + public BusyIndicator Busy => _busy; + + /// + public void Say(string message) => _ui.Post(() => _feed.AddModelMessage(message)); + + /// + public void ShowProgress(AgentProgressEvent progressEvent) => _ui.Post(() => _feed.AddStep(progressEvent)); + + /// + public void ShowDiff(FileChange change) => _ui.Post(() => _feed.AddDiff(change)); + + /// + public Task ConfirmAsync(ConfirmRequest request) => _dialogs.ConfirmAsync(request); + + /// + public Task AskAsync(string question) => _dialogs.AskAsync(question); + + /// + public Task ChooseAsync(ChoiceRequest request, CancellationToken cancellationToken = default) + => _dialogs.ChooseAsync(request, cancellationToken); + + /// + public IDisposable BeginWait(string message) => _busy.Push(message); +} diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Animations.xaml b/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Animations.xaml new file mode 100644 index 0000000..cb86b2a --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Animations.xaml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Brushes.xaml b/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Brushes.xaml new file mode 100644 index 0000000..a30e869 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Brushes.xaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Colors.xaml b/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Colors.xaml new file mode 100644 index 0000000..29fbcad --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Colors.xaml @@ -0,0 +1,32 @@ + + + + + #080B16 + #0E1428 + + #0FFFFFFF + #1AFFFFFF + #26FFFFFF + #24FFFFFF + + #F2F4FA + #A9B1CC + #8F98B5 + + #E3C07A + #F0D29A + #C9A35F + + #8B7CFF + + #5CD6A0 + #F5B94C + #F26D7D + #6CB4FF + + #265CD6A0 + #26F26D7D + + diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Controls.Inputs.xaml b/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Controls.Inputs.xaml new file mode 100644 index 0000000..58eb213 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Controls.Inputs.xaml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Controls.xaml b/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Controls.xaml new file mode 100644 index 0000000..79556cf --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Controls.xaml @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Converters.xaml b/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Converters.xaml new file mode 100644 index 0000000..2ba6e62 --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Converters.xaml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Dialogs.xaml b/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Dialogs.xaml new file mode 100644 index 0000000..54602de --- /dev/null +++ b/src/UI/ImanSoftware.KeylessAgent.Wpf/Themes/Dialogs.xaml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + +