From 00bc1fcda1c3a804ad200cf7abc98484c34e44ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Aslo-=C3=98stergaard?= Date: Fri, 6 Nov 2026 17:02:06 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(status):=20OSC=207501=20program=20stat?= =?UTF-8?q?us=20=E2=80=94=20working=20state,=20exact=20waiting=20dots,=20h?= =?UTF-8?q?euristic=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support the Program Status Protocol (OSC 7501) so programs report their own state. Part 1 of #154. - terminal-init.js: OSC 7501 handler (query -> programStatusProbe, report -> programStatus) and an observe-only OSC 133 A handler (promptStart). - TerminalBridge answers the `OSC 7501 ; ?` probe with fixed, app-authored bytes, encoded as win32-input-mode key events: the inbox conhost drops OSC sequences written to the ConPTY input as plain VT (measured). - Services/ProgramStatusPayload: strict WPF-free parser/validator (alphabet, base64 + UTF-8, control chars, spec limits; bad reports dropped whole). - Services/ProgramStatusRecords: record tree with replace/clear/eviction, exit lifetime, acknowledge, and blocked > working > error > done > idle. - SessionViewModel maps the aggregate: working -> new pulsing "working · N%" indicator (sidebar + toolbar), blocked+permission -> approval, other blocked -> input, done/error -> attention badge with a rate-limited toast. AlertDetector stands down once 7501 is seen; user interaction acknowledges done/error but keeps live states. - Docs: CLAUDE.md section incl. the measured ConPTY limitation (conhost answers DA1 itself, so Claude 2.1.319's probe still settles to "no"). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX --- CLAUDE.md | 93 ++++- README.md | 1 + docs/shell-integration.md | 14 + src/CodeShellManager/Assets/terminal-init.js | 35 ++ .../MainWindow.ProgramStatus.cs | 86 +++++ src/CodeShellManager/MainWindow.xaml.cs | 30 ++ .../Services/AlertDetector.cs | 21 +- .../Services/ProgramStatusPayload.cs | 280 +++++++++++++++ .../Services/ProgramStatusRecords.cs | 168 +++++++++ .../Terminal/TerminalBridge.cs | 34 ++ .../ViewModels/MainViewModel.cs | 23 ++ .../ViewModels/SessionViewModel.cs | 144 ++++++++ .../ProgramStatusPayloadTests.cs | 322 ++++++++++++++++++ .../ProgramStatusRecordsTests.cs | 190 +++++++++++ .../SessionViewModelProgramStatusTests.cs | 223 ++++++++++++ 15 files changed, 1662 insertions(+), 2 deletions(-) create mode 100644 src/CodeShellManager/MainWindow.ProgramStatus.cs create mode 100644 src/CodeShellManager/Services/ProgramStatusPayload.cs create mode 100644 src/CodeShellManager/Services/ProgramStatusRecords.cs create mode 100644 tests/CodeShellManager.Tests/ProgramStatusPayloadTests.cs create mode 100644 tests/CodeShellManager.Tests/ProgramStatusRecordsTests.cs create mode 100644 tests/CodeShellManager.Tests/SessionViewModelProgramStatusTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index fa7b0be..4613a16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,9 @@ PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js) | `GitStatusText` | WPF-free wording for the toolbar git chip and menu: ↑/↓ counts, the menu header line, the post-fetch toast, and a short hint recognised from git's own failure text. See "Git tools on the terminal toolbar" | | `WslDiscoveryService` | `wsl.exe` probes for distro list, `$HOME` and login shell, each cached per (distro, user) and capped at 3s. All three go through `RunWslCaptureAsync`, which is `Task.Run`-wrapped — **never call `Process.Start` inline here**, `wsl.exe` can boot a stopped distro VM | | `ShellIntegrationPayload` | WPF-free validation for OSC 9001. Every field is untrusted: colour is strict hex, dirty is an allowlist, title and branch strip control characters and are length-capped (`MaxTitleLength` 80, `MaxBranchLength` 200) | -| `AlertDetector` | Pattern matching for Claude prompts/approvals | +| `AlertDetector` | Pattern matching for Claude prompts/approvals. `StandDown()` silences it once a session speaks OSC 7501 | +| `ProgramStatusPayload` | WPF-free OSC 7501 report parser/validator + the probe reply (win32-input-mode encoded for ConPTY). See "Program status (OSC 7501)" | +| `ProgramStatusRecords` | WPF-free OSC 7501 record tree: replace, `clear`, 256-cap eviction, exit lifetime, acknowledge, and the blocked > working > error > done > idle aggregate | | `CommandPresetsService` | Launch presets + in-session shortcuts | | `ClaudeSessionService` | Detects `claude` invocations; finds last `--resume` session id under `~/.claude/projects/` | | `UpdateService` | GitHub Releases version check; caches result for 24h at `%AppData%/CodeShellManager/update-cache.json` | @@ -649,6 +651,95 @@ Unknown keys are ignored. Multiple keys can be sent in a single sequence. Values The OSC handler returns `true` so xterm consumes the sequence and it doesn't render. +## Program status (OSC 7501) + +Programs report their own state through the [Program Status Protocol](https://www.superlogical.com/rex/docs/build/program-status): +`ESC ] 7501 ; state=working:progress=40:id=build:app=cargo:msg= ST`. Claude Code +(checked: 2.1.319) implements it — root record `working` while busy, `blocked` + +`kind=permission|question|auth` when waiting, `done` after a completed turn, `error` on a failed +one, child records per background task, and `state=clear` on exit — **but only after its +startup query `OSC 7501 ; ?` is answered**. Unanswered, it logs `probe: no reply to OSC 7501 ; ?` +and never reports. + +**Pipeline.** `terminal-init.js` registers `registerOscHandler(7501, …)`: a `?` query posts +`programStatusProbe`, a report posts `programStatus` with the raw payload (pre-capped at 4096). +It also observes OSC 133 `A` and posts `promptStart` (returns `false` — observe only). +`TerminalBridge` answers the probe itself with fixed bytes (`ProgramStatusPayload.ProbeReplyForConPty`) +— app-authored, so a raw PTY write is correct, and it deliberately does **not** raise +`UserInput` — and raises `ProgramStatusReceived` / `PromptStarted`. `MainViewModel.ApplyProgramStatus` +→ `SessionViewModel.ApplyProgramStatus` parses (`Services/ProgramStatusPayload`), stores +(`Services/ProgramStatusRecords`) and maps; the alert count and a toast are handled in +`MainViewModel`. All WPF-free logic is tested in `ProgramStatusPayloadTests`, +`ProgramStatusRecordsTests` and `SessionViewModelProgramStatusTests`. Nothing is persisted. + +**Validation is the spec's, and strict.** Keys `[a-z]{1,16}`, values `[A-Za-z0-9_.,+/=-]*`, +malformed pairs skipped, last repeat wins. A report is dropped *whole* on a missing/unknown +state, an invalid id (it is not written to the root instead), a limit (sequence 4096, app 32, +id 128/32 per segment/8 deep, title 256→192, msg 2732→2048 bytes), bad base64, invalid UTF-8, +or any control character in decoded text. Unicode format characters (bidi overrides, +zero-width) are stripped, not rejected — the spec asks for them to be neutralised. An +out-of-range `progress` is treated as indeterminate, not as a reason to drop the report. + +**Records and precedence.** One record per id (root = no id); each report replaces its record +wholesale; `clear` removes the id and its children, or everything without an id; 256 records, +least-recently-updated evicted. The session shows one aggregate state: +**blocked > working > error > done > idle**, ties to the root, then most recent. Working +outranks error/done on purpose: those two persist until acknowledged, so ranking them higher +would let a stale child result hide live work forever. + +| Aggregate | Session | +|---|---| +| `working` (+`progress`) | `IsWorking` / `WorkProgress` → pulsing `⟳ working · 40%` in the sidebar row and `⟳ 40%` on the toolbar (accent blue, 10 fps opacity pulse, chrome only — HwndHost) | +| `blocked` + `permission` | `IsWaitingForApproval` (orange) | +| `blocked` + `question` / `auth` / no kind | `IsWaitingForInput` (green) | +| `done` / `error` | `NeedsAttention` badge | +| `idle` / none | indicators cleared | + +The badge and toast fire only on **entering** blocked/done/error (a new state or kind), never +on a report that restates the current one with a new progress or msg; toasts are further +limited to one per session per `ProgramStatusToastInterval` (5s). Leaving for working/idle +clears the badge. + +**Fallback rule.** The first *valid* report sets `HasProgramStatus` and calls +`AlertDetector.StandDown()` — the regex heuristic stops for that session's lifetime (same +pattern as `_gitOverriddenByOsc`), and `RaiseAlert` plus the queued `AlertRaised` handler both +ignore late heuristic alerts. A relaunch builds a new VM, so restart/wake re-arm it. Sessions +that never send 7501 behave exactly as before. + +**User interaction acknowledges, it does not clear.** For a 7501 session `ClearAlert` +(keystroke via `NotifyUserInteracted`, sidebar focus) clears the badge and retires `done`/`error` +records — "a result you haven't seen" — but leaves the waiting dots and the working indicator +alone: they describe what the program is doing now, and it will report its next state itself. +Clearing them would blank an orange "needs permission" dot on the first keystroke. + +**Lifetime.** PTY exit and OSC 133 `A` call `DropTransient`: working, blocked and idle records go; +done and error stay until acknowledged. A full reset (`RIS`) is not tracked. Note the PTY is +usually `pwsh -NoExit -Command claude`, so "process exit" is the wrapper's — Claude's own +`state=clear` on exit is what normally retires its records. + +### The probe reply cannot win under the inbox ConPTY (measured) + +Two conhost behaviours, both measured on 10.0.26100 with a raw-mode echo program behind the +app's own `PseudoTerminal` and against the real `claude`: + +1. **Conhost drops OSC on the input side.** Writing `ESC ] 7501 ; ? ST` (either terminator) — or + any OSC, e.g. an OSC 11 colour reply — to the ConPTY input never reaches the program; CSI and + DCS replies do. Hence `ProbeReplyForConPty`: the same bytes sent as win32-input-mode key events + (`ESC [ 0;0;;1;0;1 _` per character), which conhost turns back into exactly those + characters. Verified: the program receives `ESC ] 7501 ; ? ESC \` intact. +2. **Conhost answers `CSI c` (DA1) itself.** The query never reaches xterm.js; conhost injects + `ESC [ ? 61;6;7;… c` into the input immediately. Claude sends DA1 straight after its 7501 + query as a "no more replies" sentinel, so conhost's answer always arrives *before* ours can + make the round trip through the page and back — Claude logs "no reply" and settles to off. + +So with Claude 2.1.319 on the inbox ConPTY, **no reports arrive yet**; everything above is +correct and tested, and lights up as soon as either side moves. Paths, none taken here: +bundling a newer ConPTY (`Microsoft.Windows.Console.ConPTY`: `conpty.dll` + `OpenConsole.exe`) +if it forwards OSC input and doesn't answer DA1 itself (unverified); or Claude Code accepting a +late reply / an env opt-in. Pre-seeding the reply before the program asks was rejected — in a +non-probing program it would be stray keystrokes in the shell. Any program that reports without +querying (the spec makes detection optional) works today. + ## Sleep / Wake (Dormant Sessions) Sessions can be put to sleep instead of closed — the PTY is torn down but the `ShellSession` is kept in `state.json` (`IsDormant = true`) so it can be relaunched from the sidebar later. Useful when you have many long-running projects but only need a few live at once. diff --git a/README.md b/README.md index d6d81d4..ca72648 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Spotted something out of date? Competitors move fast. [Open an issue](https://gi - **Full-text search** — all terminal output indexed to SQLite FTS5; instant search across every session, ever - **Per-project notepad** — collapsible 📝 notes panel on every terminal, auto-saved and searchable - **Alert detection** — detects when Claude is waiting for input or tool approval; green/orange dot indicators +- **Program status (OSC 7501)** — programs that report their own state via the [Program Status Protocol](https://www.superlogical.com/rex/docs/build/program-status) get a pulsing `⟳ working · 40%` indicator in the sidebar and toolbar, exact waiting-for-input / approval dots, and a badge when they finish or fail; sessions that don't report it keep the heuristic detection - **Git status** — shows branch and dirty state in the sidebar per session - **Git tools on the terminal toolbar** — a chip like `⎇ main ● ↑2 ↓5` (branch, uncommitted changes, ahead/behind) on every repo session; its menu does Fetch, fast-forward-only Pull, Status, and Switch branch (worktree-aware). Deliberately read-mostly: no commit, push or merge, and nothing is ever typed into the terminal - **Open in VS Code / Explorer / PowerShell** — `{ }`, 📁 and `>_` on the terminal toolbar (and in the sidebar right-click menu) open the session's folder; VS Code appears when it is installed with `code` on PATH diff --git a/docs/shell-integration.md b/docs/shell-integration.md index 12c9a8b..fbb0ab6 100644 --- a/docs/shell-integration.md +++ b/docs/shell-integration.md @@ -2,6 +2,20 @@ Programs running inside a CodeShellManager terminal can push session state up to the host UI by emitting a custom OSC (Operating System Command) escape sequence. This is the recommended way for tools like SSH overlays, REPLs, and TUI apps to keep CSM's accent color, git status, and tab title in sync with whatever the program actually represents — even when CSM cannot inspect that state locally. +> **Reporting working / waiting / done?** Use the standard **OSC 7501 Program Status Protocol** +> ([spec](https://www.superlogical.com/rex/docs/build/program-status)) rather than OSC 9001. CSM +> answers the `OSC 7501 ; ? ST` feature query and maps reports onto the session: +> `working` (+`progress`) → a `⟳ working · 40%` indicator in the sidebar and toolbar; +> `blocked` + `kind=permission` → orange approval dot; other `blocked` → green input dot; +> `done` / `error` → "needs attention" badge and a rate-limited tray notification; `idle` / `clear` +> → indicators cleared. Once a session sends any valid 7501 report, CSM's own screen-scraping +> prompt detection stands down for that session. Status is runtime-only — never saved. +> +> Windows note: the inbox ConPTY answers the `CSI c` device-attributes query itself, before CSM +> can reply to a `?` query sent just ahead of it, so a program that treats that answer as "no more +> replies are coming" will conclude CSM does not support 7501. Programs can always send reports +> without querying first — the spec makes detection optional. + ## Wire format ``` diff --git a/src/CodeShellManager/Assets/terminal-init.js b/src/CodeShellManager/Assets/terminal-init.js index 444f8ec..18e8b32 100644 --- a/src/CodeShellManager/Assets/terminal-init.js +++ b/src/CodeShellManager/Assets/terminal-init.js @@ -133,6 +133,41 @@ return true; }); + // ── Program status: OSC 7501 ─────────────────────────────────────────────── + // ESC ] 7501 ; state=working:progress=40:id=build:app=cargo ST (a report) + // ESC ] 7501 ; ? ST (feature query) + // Parsing and validation are on the C# side (ProgramStatusPayload) — this only routes. + // + // The query MUST be answered: Claude Code sends it at startup and, with no reply, never + // reports status. The host writes fixed reply bytes to the PTY (not via 'input': a reply + // is not the user typing). Posted synchronously from inside the parser so it is handled + // as early as possible — the program treats its device-attributes answer (CSI c, sent + // right after this query) as "no more replies coming". See CLAUDE.md "Program status + // (OSC 7501)": under the inbox ConPTY that race is currently lost, because conhost + // answers CSI c itself before the query ever reaches this page. + term.parser.registerOscHandler(7501, data => { + try { + const s = String(data); + if (s.trim().charAt(0) === '?') { + window.chrome.webview.postMessage(JSON.stringify({ type: 'programStatusProbe' })); + } else if (s.length <= 4096) { + // Cheap pre-filter for the spec's 4096-byte sequence cap; the host re-checks bytes. + window.chrome.webview.postMessage(JSON.stringify({ type: 'programStatus', data: s })); + } + } catch {} + return true; + }); + + // OSC 133 A — "a new shell prompt starts". Per the OSC 7501 spec, working/blocked records + // are dropped here (there is no heartbeat). Returns false: we only observe it. + term.parser.registerOscHandler(133, data => { + try { + if (String(data).charAt(0) === 'A') + window.chrome.webview.postMessage(JSON.stringify({ type: 'promptStart' })); + } catch {} + return false; + }); + // ── Input → PTY ──────────────────────────────────────────────────────────── function sendInput(data) { window.chrome.webview.postMessage(JSON.stringify({ type: 'input', data })); diff --git a/src/CodeShellManager/MainWindow.ProgramStatus.cs b/src/CodeShellManager/MainWindow.ProgramStatus.cs new file mode 100644 index 0000000..bc54903 --- /dev/null +++ b/src/CodeShellManager/MainWindow.ProgramStatus.cs @@ -0,0 +1,86 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Animation; +using CodeShellManager.ViewModels; +using Color = System.Windows.Media.Color; + +namespace CodeShellManager; + +/// +/// The "working" indicator driven by OSC 7501 program status — a small pulsing +/// ⟳ working · 40% in the sidebar row and on the terminal toolbar. It lives in that +/// chrome on purpose: WebView2 is an HwndHost, so nothing WPF draws can appear over a +/// terminal pane (see "Session Spinners" in CLAUDE.md). +/// +public partial class MainWindow +{ + // Catppuccin accent blue — the same colour as the restore rail ("something is in progress"). + private static readonly SolidColorBrush s_workingBrush = Freeze(new SolidColorBrush(Color.FromRgb(0x89, 0xb4, 0xfa))); + + // One frozen animation shared by every indicator. Capped at 10 fps: a slow opacity pulse + // gains nothing from 60, and a fleet of working sessions must not cost a frame each. + private static readonly DoubleAnimation s_workingPulse = CreateWorkingPulse(); + + private static DoubleAnimation CreateWorkingPulse() + { + var anim = new DoubleAnimation + { + From = 1.0, + To = 0.45, + Duration = TimeSpan.FromMilliseconds(1100), + AutoReverse = true, + RepeatBehavior = RepeatBehavior.Forever + }; + Timeline.SetDesiredFrameRate(anim, 10); + anim.Freeze(); + return anim; + } + + private static SolidColorBrush Freeze(SolidColorBrush b) { b.Freeze(); return b; } + + /// Builds a collapsed indicator; drive it with . + private static TextBlock BuildWorkingIndicator(Thickness margin) => new() + { + Foreground = s_workingBrush, + FontSize = 10, + Margin = margin, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + Visibility = Visibility.Collapsed + }; + + /// + /// Shows/hides and relabels a working indicator from the VM. The pulse runs only while + /// visible — a collapsed element with a running clock still ticks. + /// + private static void UpdateWorkingIndicator(TextBlock tb, SessionViewModel vm, bool compact) + { + if (!vm.IsWorking) + { + if (tb.Visibility != Visibility.Collapsed) + { + tb.BeginAnimation(UIElement.OpacityProperty, null); + tb.Visibility = Visibility.Collapsed; + } + return; + } + + string pct = vm.WorkProgress is int p ? $"{p}%" : ""; + tb.Text = compact + ? (pct.Length > 0 ? $"⟳ {pct}" : "⟳ working") + : (pct.Length > 0 ? $"⟳ working · {pct}" : "⟳ working"); + // msg is decoded, control-character-free and format-character-stripped by + // ProgramStatusPayload; a ToolTip renders it as plain text. + tb.ToolTip = string.IsNullOrEmpty(vm.ProgramStatusMessage) + ? "Working (reported by the program)" + : vm.ProgramStatusMessage; + + if (tb.Visibility != Visibility.Visible) + { + tb.Visibility = Visibility.Visible; + tb.BeginAnimation(UIElement.OpacityProperty, s_workingPulse); + } + } +} diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index af9e80b..14d57bc 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -1560,6 +1560,14 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal _vm.SaveStateDebounced(); }; + // OSC 7501 program status (Claude Code reports working / blocked / done through it + // once the probe is answered — TerminalBridge does that). Runtime-only: no save. + // WebMessageReceived already runs on the UI thread, so Invoke runs inline. + bridge.ProgramStatusReceived += payload => + Dispatcher.Invoke(() => _vm.ApplyProgramStatus(vm, payload)); + bridge.PromptStarted += () => + Dispatcher.Invoke(() => _vm.DropTransientProgramStatus(vm)); + string assetsDir = Path.Combine(AppContext.BaseDirectory, "Assets"); bool wantTransparent = session.ProfileBackgroundOpacity is < 1.0; string htmlFile = wantTransparent ? "terminal-transparent.html" : "terminal.html"; @@ -1639,6 +1647,8 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal Dispatcher.Invoke(() => { _sessionManager.UpdateStatus(session.Id, SessionStatus.Exited); + // OSC 7501 lifetime: working/blocked records die with the process. + _vm.DropTransientProgramStatus(vm); RefreshSidebarItem(session.Id); }); }; @@ -1964,6 +1974,10 @@ static void UpdateGitText(TextBlock tb, SessionViewModel svm) textPanel.Children.Add(folderText); textPanel.Children.Add(gitText); textPanel.Children.Add(worktreeText); + // OSC 7501 "working" — built collapsed, shown only while the program reports working. + var workText = BuildWorkingIndicator(new Thickness(0, 1, 0, 0)); + UpdateWorkingIndicator(workText, vm, compact: false); + textPanel.Children.Add(workText); textPanel.Children.Add(alertBadge); // Status dot (waiting indicator) @@ -2155,6 +2169,11 @@ static void UpdateGitText(TextBlock tb, SessionViewModel svm) UpdateWorktreeText(); break; + // One notification for working + progress + message (OSC 7501). + case nameof(SessionViewModel.ProgramStatusVersion): + UpdateWorkingIndicator(workText, vm, compact: false); + break; + case nameof(SessionViewModel.RepoRoot): RecomputeWorktreeSiblings(); UpdateWorktreeText(); @@ -4679,6 +4698,11 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView) DockPanel.SetDock(chevronBtn, Dock.Right); DockPanel.SetDock(playBtn, Dock.Right); DockPanel.SetDock(gitChip, Dock.Right); + // OSC 7501 working indicator, just left of the git chip. Toolbar chrome, never over + // the pane — WebView2 is an HwndHost and would cover any WPF overlay. + var workChip = BuildWorkingIndicator(new Thickness(0, 0, 8, 0)); + UpdateWorkingIndicator(workChip, vm, compact: true); + DockPanel.SetDock(workChip, Dock.Right); DockPanel.SetDock(claudeBadge, Dock.Left); DockPanel.SetDock(titleBlock, Dock.Left); // Dock.Right children are stacked right-to-left in declaration order, so this @@ -4695,6 +4719,7 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView) toolbarContent.Children.Add(chevronBtn); toolbarContent.Children.Add(playBtn); toolbarContent.Children.Add(gitChip); + toolbarContent.Children.Add(workChip); toolbarContent.Children.Add(claudeBadge); toolbarContent.Children.Add(titleBlock); toolbarContent.Children.Add(folderBlock); @@ -4865,6 +4890,11 @@ private Border BuildTerminalWrapper(SessionViewModel vm, WebView2 webView) // Subscribe to waiting state changes for the status dot vm.PropertyChanged += (_, args) => { + if (args.PropertyName == nameof(SessionViewModel.ProgramStatusVersion)) + { + Dispatcher.Invoke(() => UpdateWorkingIndicator(workChip, vm, compact: true)); + return; + } if (args.PropertyName is nameof(SessionViewModel.IsWaitingForInput) or nameof(SessionViewModel.IsWaitingForApproval)) { diff --git a/src/CodeShellManager/Services/AlertDetector.cs b/src/CodeShellManager/Services/AlertDetector.cs index 01b6248..acb37b6 100644 --- a/src/CodeShellManager/Services/AlertDetector.cs +++ b/src/CodeShellManager/Services/AlertDetector.cs @@ -16,6 +16,7 @@ public partial class AlertDetector : IDisposable private string _lastLine = ""; private System.Threading.Timer? _idleTimer; private bool _alertFired; + private volatile bool _stoodDown; public event Action? AlertRaised; public event Action? AlertCleared; // sessionId @@ -26,8 +27,26 @@ public AlertDetector(string sessionId, string sessionName) _sessionName = sessionName; } + /// True once ran; sticky for this detector's lifetime. + public bool IsStoodDown => _stoodDown; + + /// + /// The program reports its own state through OSC 7501, so the screen heuristic stops + /// guessing — its regexes would otherwise fight the reported state, e.g. raise + /// "approval needed" off a line of output while the program says it is working. Same + /// pattern as the git poller standing down after an OSC 9001 branch. User-interaction + /// notifications still fire: they are how a session acknowledges a done/error state. + /// + public void StandDown() + { + _stoodDown = true; + _idleTimer?.Dispose(); + _idleTimer = null; + } + public void Feed(string rawOutput) { + if (_stoodDown) return; string clean = StripAnsi(rawOutput); var lines = clean.Split('\n', StringSplitOptions.RemoveEmptyEntries); @@ -52,7 +71,7 @@ public void NotifyUserInteracted() private void OnIdle(object? _) { - if (_alertFired) return; + if (_alertFired || _stoodDown) return; if (string.IsNullOrWhiteSpace(_lastLine)) return; AlertType? alertType = null; diff --git a/src/CodeShellManager/Services/ProgramStatusPayload.cs b/src/CodeShellManager/Services/ProgramStatusPayload.cs new file mode 100644 index 0000000..e71622b --- /dev/null +++ b/src/CodeShellManager/Services/ProgramStatusPayload.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace CodeShellManager.Services; + +/// The state of an OSC 7501 report. +public enum ProgramState +{ + Idle, + Working, + Done, + Blocked, + Error, + /// An action, not a state: removes the addressed record and its children. + Clear, +} + +/// The kind of a report. +public enum BlockedKind +{ + /// Not given, or given with a state other than blocked, or unrecognised. + None, + Permission, + Question, + Auth, +} + +/// One validated OSC 7501 report. is null for the root record. +public sealed record ProgramStatusReport( + ProgramState State, + BlockedKind Kind = BlockedKind.None, + int? Progress = null, + string? App = null, + string? Id = null, + string? Title = null, + string? Message = null); + +/// +/// Parser and validator for the OSC 7501 Program Status Protocol +/// (https://www.superlogical.com/rex/docs/build/program-status). WPF-free so every rule is +/// unit-tested (ProgramStatusPayloadTests). +/// +/// Wire form: ESC ] 7501 ; key=value:key=value ST. Everything here is untrusted +/// terminal output — a remote host, a cat of some file — so a report that breaks a +/// limit, carries invalid base64 or decodes to a control character is dropped WHOLE, as the +/// spec requires; nothing from it is applied. +/// +/// +public static class ProgramStatusPayload +{ + // ── Limits, all from the spec ─────────────────────────────────────────────── + /// The whole escape sequence, introducer and terminator included. + public const int MaxSequenceBytes = 4096; + public const int MaxKeyBytes = 16; + public const int MaxMsgEncodedBytes = 2732; + public const int MaxMsgDecodedBytes = 2048; + public const int MaxTitleEncodedBytes = 256; + public const int MaxTitleDecodedBytes = 192; + public const int MaxAppBytes = 32; + public const int MaxIdBytes = 128; + public const int MaxIdSegmentBytes = 32; + public const int MaxIdDepth = 8; + + // "ESC ] 7501 ;" (7 bytes) + the longest ST, "ESC \" (2 bytes). The page hands us the + // payload only, so the envelope is added back before comparing to MaxSequenceBytes. + private const int EnvelopeBytes = 9; + + /// + /// The terminal's answer to the feature query OSC 7501 ; ? ST. Fixed bytes, authored + /// here — nothing from the query is echoed. Omitting states=/kinds= means + /// "the baseline sets", which is exactly what we accept. + /// + /// Required, not optional: Claude Code sends the query at startup and, if it gets no reply + /// before its device-attributes sentinel, logs probe: no reply to OSC 7501 ; ? and + /// never reports status at all. + /// + /// + public const string ProbeReply = "\x1b]7501;?\x1b\\"; + + /// + /// as it must be WRITTEN to a Windows ConPTY: each character sent + /// as a win32-input-mode key event (ESC [ Vk ; Sc ; Uc ; Kd ; Cs ; Rc _). + /// + /// Measured against the inbox conhost (10.0.26100): its VT input parser silently DROPS + /// every OSC sequence written to the pseudo console's input — the plain reply, BEL- or + /// ST-terminated, never reaches the program (CSI and DCS replies do). Encoded as key + /// events, the same bytes arrive at the program intact. See CLAUDE.md "Program status + /// (OSC 7501)" for the remaining limitation (conhost answers DA1 itself, first). + /// + /// + public static readonly string ProbeReplyForConPty = EncodeAsWin32InputMode(ProbeReply); + + /// + /// Encodes text as win32-input-mode key-down events, one per UTF-16 unit, which conhost + /// turns back into the same characters for the program. Only ever applied to fixed, + /// app-authored bytes. + /// + internal static string EncodeAsWin32InputMode(string text) + { + var sb = new StringBuilder(text.Length * 16); + foreach (char c in text) + sb.Append("\x1b[0;0;").Append((int)c).Append(";1;0;1_"); + return sb.ToString(); + } + + /// True when the OSC payload is the feature query (?) rather than a report. + public static bool IsQuery(string? payload) => + payload != null && payload.Trim().StartsWith('?'); + + /// + /// Parses and validates one report payload (the text after 7501;). Returns null when + /// the report must be ignored: missing or unknown state, an invalid id, a limit exceeded, + /// bad base64 or a control character in decoded text. + /// + public static ProgramStatusReport? TryParse(string? payload) + { + if (string.IsNullOrEmpty(payload)) return null; + if (Encoding.UTF8.GetByteCount(payload) + EnvelopeBytes > MaxSequenceBytes) return null; + + var fields = new Dictionary(StringComparer.Ordinal); + foreach (var rawPair in payload.Split(':')) + { + string pair = rawPair.Trim(); + int eq = pair.IndexOf('='); + if (eq <= 0) continue; // malformed → skip + string key = pair[..eq].Trim(); + string value = pair[(eq + 1)..].Trim(); + if (!IsValidKey(key) || !IsValidValue(value)) continue; // malformed → skip + fields[key] = value; // repeated → last wins + } + + if (!fields.TryGetValue("state", out var stateText)) return null; + ProgramState? state = stateText switch + { + "idle" => ProgramState.Idle, + "working" => ProgramState.Working, + "done" => ProgramState.Done, + "blocked" => ProgramState.Blocked, + "error" => ProgramState.Error, + "clear" => ProgramState.Clear, + _ => null, + }; + if (state is null) return null; + + string? id = null; + if (fields.TryGetValue("id", out var idText)) + { + // An invalid id ignores the report rather than falling back to the root — writing + // a malformed child report over the root would be a worse outcome than dropping it. + if (!IsValidId(idText)) return null; + id = idText; + } + + string? app = null; + if (fields.TryGetValue("app", out var appText)) + { + if (appText.Length > MaxAppBytes) return null; // ASCII alphabet: chars == bytes + if (appText.Length > 0) app = appText; + } + + string? title = null; + if (fields.TryGetValue("title", out var titleB64)) + { + if (titleB64.Length > MaxTitleEncodedBytes) return null; + if (!TryDecodeText(titleB64, MaxTitleDecodedBytes, out title)) return null; + } + + string? msg = null; + if (fields.TryGetValue("msg", out var msgB64)) + { + if (msgB64.Length > MaxMsgEncodedBytes) return null; + if (!TryDecodeText(msgB64, MaxMsgDecodedBytes, out msg)) return null; + } + + BlockedKind kind = BlockedKind.None; + if (state == ProgramState.Blocked && fields.TryGetValue("kind", out var kindText)) + { + kind = kindText switch + { + "permission" => BlockedKind.Permission, + "question" => BlockedKind.Question, + "auth" => BlockedKind.Auth, + _ => BlockedKind.None, + }; + } + + int? progress = null; + if (state is ProgramState.Working or ProgramState.Blocked + && fields.TryGetValue("progress", out var progressText) + && progressText.Length is > 0 and <= 3 + && int.TryParse(progressText, NumberStyles.None, CultureInfo.InvariantCulture, out int p) + && p is >= 0 and <= 100) + { + // Out-of-range or non-integer progress is treated as absent (indeterminate). It is + // not a size limit, so it does not justify dropping an otherwise valid report. + progress = p; + } + + return new ProgramStatusReport(state.Value, kind, progress, app, id, + string.IsNullOrEmpty(title) ? null : title, + string.IsNullOrEmpty(msg) ? null : msg); + } + + internal static bool IsValidKey(string key) + { + if (key.Length == 0 || key.Length > MaxKeyBytes) return false; + foreach (char c in key) + if (c is < 'a' or > 'z') return false; + return true; + } + + internal static bool IsValidValue(string value) + { + foreach (char c in value) + { + bool ok = c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' + or '_' or '.' or ',' or '+' or '/' or '=' or '-'; + if (!ok) return false; + } + return true; + } + + /// Slash path: 1–8 non-empty segments of ≤32 bytes, ≤128 bytes in all. + internal static bool IsValidId(string id) + { + if (id.Length == 0 || id.Length > MaxIdBytes) return false; + var segments = id.Split('/'); + if (segments.Length > MaxIdDepth) return false; + foreach (var s in segments) + if (s.Length == 0 || s.Length > MaxIdSegmentBytes) return false; + return true; + } + + /// + /// Standard base64 with optional padding → strict UTF-8, single line, no control + /// characters (C0, DEL, C1). Unicode format characters — bidi overrides, zero-width + /// joiners and the like — are removed rather than rejected: the spec asks terminals to + /// neutralise them, and they are invisible, so stripping loses nothing a user could see. + /// + internal static bool TryDecodeText(string b64, int maxDecodedBytes, out string? text) + { + text = null; + if (b64.Length == 0) { text = ""; return true; } + if (b64.Contains('-') || b64.Contains('_') || b64.Contains(',') || b64.Contains('.')) + return false; // not standard base64 + + string body = b64.TrimEnd('='); + int padCount = b64.Length - body.Length; + if (padCount > 2 || body.Contains('=')) return false; + if (body.Length % 4 == 1) return false; + string padded = body.Length % 4 == 0 ? body : body + new string('=', 4 - body.Length % 4); + if (padCount > 0 && padded.Length != b64.Length) return false; // wrong amount of padding + + var bytes = new byte[padded.Length / 4 * 3]; + if (!Convert.TryFromBase64String(padded, bytes, out int written)) return false; + if (written > maxDecodedBytes) return false; + + string decoded; + try + { + decoded = new UTF8Encoding(false, throwOnInvalidBytes: true).GetString(bytes, 0, written); + } + catch (DecoderFallbackException) + { + return false; + } + + var sb = new StringBuilder(decoded.Length); + foreach (char c in decoded) + { + if (char.IsControl(c)) return false; // C0, DEL, C1 — incl. CR/LF + if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.Format) continue; + sb.Append(c); + } + text = sb.ToString(); + return true; + } +} diff --git a/src/CodeShellManager/Services/ProgramStatusRecords.cs b/src/CodeShellManager/Services/ProgramStatusRecords.cs new file mode 100644 index 0000000..3f8ec06 --- /dev/null +++ b/src/CodeShellManager/Services/ProgramStatusRecords.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CodeShellManager.Services; + +/// +/// The one state a session shows, derived from all of its OSC 7501 records. +/// is null when there are no records (nothing to show). +/// +public sealed record ProgramStatusSummary( + ProgramState? State, + BlockedKind Kind = BlockedKind.None, + int? Progress = null, + string? Message = null, + string? App = null) +{ + public static readonly ProgramStatusSummary Empty = new((ProgramState?)null); + + /// States that warrant the "needs attention" badge. + public bool WantsAttention => State is ProgramState.Blocked or ProgramState.Done or ProgramState.Error; +} + +/// A session just entered an attention state and the toast rate limit allows one. +public sealed record ProgramStatusNotice(string Message); + +/// +/// Per-terminal store of OSC 7501 records, with the spec's replacement, clear, +/// eviction and lifetime rules, plus the aggregation into one . +/// WPF-free and single-threaded (the owner calls it on the UI thread); unit-tested in +/// ProgramStatusRecordsTests. +/// +public sealed class ProgramStatusRecords +{ + /// Spec cap: 256 records per terminal; least recently updated is evicted. + public const int MaxRecords = 256; + + // Root record is keyed by "" — ids are validated non-empty, so it cannot collide. + private const string RootKey = ""; + + private sealed record Entry(ProgramStatusReport Report, long Seq); + + private readonly Dictionary _records = new(StringComparer.Ordinal); + private long _seq; + + public int Count => _records.Count; + + /// Applies one validated report. Returns true when the stored records changed. + public bool Apply(ProgramStatusReport report) + { + if (report.State == ProgramState.Clear) + { + if (report.Id is null) + { + if (_records.Count == 0) return false; + _records.Clear(); + return true; + } + return RemoveSubtree(report.Id); + } + + string key = report.Id ?? RootKey; + // Each report fully REPLACES its record — nothing carries over from the previous one. + _records[key] = new Entry(report, ++_seq); + + if (_records.Count > MaxRecords) + { + string victim = _records.Where(kv => kv.Key != key) + .MinBy(kv => kv.Value.Seq).Key; + _records.Remove(victim); + } + return true; + } + + /// + /// The attached process exited, or a new shell prompt began (OSC 133 A). Per the spec, + /// working and blocked records are dropped — there is no heartbeat, so nothing else would + /// ever retire them. Idle records are dropped too (the spec allows it, and an idle record + /// from a program that is gone describes nothing). Done and error persist: they are + /// results the user has not looked at yet. + /// + public bool DropTransient() => + RemoveWhere(r => r.State is ProgramState.Working or ProgramState.Blocked or ProgramState.Idle); + + /// + /// The user has seen the session (focused it, typed in it). Done and error mean "a result + /// you have not seen yet", so they are retired; live states are left for the program to + /// change. Returns true when anything was removed. + /// + public bool Acknowledge() => + RemoveWhere(r => r.State is ProgramState.Done or ProgramState.Error); + + /// app for a record, inherited from the nearest ancestor that has one. + public string? EffectiveApp(string? id) + { + string? current = id; + while (true) + { + if (_records.TryGetValue(current ?? RootKey, out var e) && e.Report.App is { } app) + return app; + if (current is null) return null; + int slash = current.LastIndexOf('/'); + current = slash < 0 ? null : current[..slash]; + } + } + + /// + /// Aggregates every record into one session state. Precedence: + /// blocked > working > error > done > idle. + /// + /// Blocked first because it is the only state that stops on the user. Working outranks + /// error and done because those two persist until acknowledged (see the lifetime rules) + /// while working is live — ranking a persisted result above it would let a stale child + /// record hide work that is happening right now, forever. Ties go to the root record, + /// then to the most recently updated one. + /// + /// + public ProgramStatusSummary Summarize() + { + if (_records.Count == 0) return ProgramStatusSummary.Empty; + + KeyValuePair? best = null; + foreach (var kv in _records) + { + if (best is null || Beats(kv, best.Value)) best = kv; + } + + var (key, entry) = best!.Value; + var r = entry.Report; + return new ProgramStatusSummary(r.State, r.Kind, r.Progress, r.Message, + EffectiveApp(key.Length == 0 ? null : key)); + } + + private static int Rank(ProgramState s) => s switch + { + ProgramState.Blocked => 4, + ProgramState.Working => 3, + ProgramState.Error => 2, + ProgramState.Done => 1, + _ => 0, + }; + + private static bool Beats(KeyValuePair a, KeyValuePair b) + { + int ra = Rank(a.Value.Report.State), rb = Rank(b.Value.Report.State); + if (ra != rb) return ra > rb; + bool aRoot = a.Key.Length == 0, bRoot = b.Key.Length == 0; + if (aRoot != bRoot) return aRoot; + return a.Value.Seq > b.Value.Seq; + } + + private bool RemoveSubtree(string id) + { + string prefix = id + "/"; + var doomed = _records.Keys + .Where(k => k == id || k.StartsWith(prefix, StringComparison.Ordinal)) + .ToList(); + foreach (var k in doomed) _records.Remove(k); + return doomed.Count > 0; + } + + private bool RemoveWhere(Func predicate) + { + var doomed = _records.Where(kv => predicate(kv.Value.Report)).Select(kv => kv.Key).ToList(); + foreach (var k in doomed) _records.Remove(k); + return doomed.Count > 0; + } +} diff --git a/src/CodeShellManager/Terminal/TerminalBridge.cs b/src/CodeShellManager/Terminal/TerminalBridge.cs index 2c68df0..ab7913b 100644 --- a/src/CodeShellManager/Terminal/TerminalBridge.cs +++ b/src/CodeShellManager/Terminal/TerminalBridge.cs @@ -119,6 +119,16 @@ public sealed class TerminalBridge : IDisposable /// public event Action>? ShellIntegrationReceived; + /// + /// Fires with the raw payload of an OSC 7501 program-status report (the text after + /// 7501;). Untrusted and unparsed — + /// validates it. The feature query is answered here and never raised. + /// + public event Action? ProgramStatusReceived; + + /// OSC 133 A: a new shell prompt started (OSC 7501 drops live records on it). + public event Action? PromptStarted; + /// /// Fires when the user presses a keyboard accelerator (Ctrl-combo, F-key, etc.) /// while the WebView2 has focus. Subscribers set e.Handled = true to prevent @@ -674,6 +684,30 @@ private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceived } break; + // OSC 7501 feature query ("OSC 7501 ; ? ST"). Answered with FIXED bytes the app + // authored — nothing from the page or the program is echoed — so a raw PTY + // write is correct here (it is not untrusted text; see PasteToTerminal). + // Deliberately does NOT raise UserInput: a terminal reply is not the user. + // Win32-input-mode encoded, because conhost drops OSC sequences written to + // the ConPTY input as plain VT — see ProgramStatusPayload.ProbeReplyForConPty. + case "programStatusProbe": + Trace("OSC7501 probe answered"); + _pty?.Write(Services.ProgramStatusPayload.ProbeReplyForConPty); + break; + + case "programStatus": + { + string payload = root.TryGetProperty("data", out var psEl) + && psEl.ValueKind == JsonValueKind.String + ? psEl.GetString() ?? "" : ""; + if (payload.Length > 0) ProgramStatusReceived?.Invoke(payload); + break; + } + + case "promptStart": + PromptStarted?.Invoke(); + break; + case "filesDropped": // JS sends full paths via text/uri-list (file:// URIs from Explorer). // diff --git a/src/CodeShellManager/ViewModels/MainViewModel.cs b/src/CodeShellManager/ViewModels/MainViewModel.cs index 3a829d1..a9f407a 100644 --- a/src/CodeShellManager/ViewModels/MainViewModel.cs +++ b/src/CodeShellManager/ViewModels/MainViewModel.cs @@ -345,6 +345,9 @@ void Promote() { App.Current.Dispatcher.BeginInvoke(() => { + // Queued before the first OSC 7501 report landed: the reported state + // wins, and the heuristic's toast must not fire either. + if (vm.HasProgramStatus) return; vm.RaiseAlert(alert.Message, alert.Type); RaiseAlertCountIfChanged(); if (Settings.ShowToastNotifications) @@ -372,6 +375,26 @@ void Promote() _ = SaveStateAsync(); } + /// + /// Applies one OSC 7501 payload to a session (UI thread). Badge/dot/working state are + /// set by the VM; the alert count and the — rate-limited — toast are handled here, + /// mirroring what the heuristic AlertRaised path does. + /// + public void ApplyProgramStatus(SessionViewModel vm, string payload) + { + var notice = vm.ApplyProgramStatus(payload, DateTime.UtcNow); + RaiseAlertCountIfChanged(); + if (notice != null && !string.IsNullOrEmpty(notice.Message) && Settings.ShowToastNotifications) + ToastHelper.Show(vm.DisplayName, notice.Message, Settings.ShowNotificationSound); + } + + /// The session's process exited or a new prompt began: drop live 7501 records. + public void DropTransientProgramStatus(SessionViewModel vm) + { + vm.DropTransientProgramStatus(); + RaiseAlertCountIfChanged(); + } + private void OnSessionCloseRequested(SessionViewModel vm) { vm.CloseRequested -= OnSessionCloseRequested; diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs index dbfc3de..ee9d74d 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -384,8 +384,139 @@ public void ClearColorOverride() OnPropertyChanged(nameof(AccentColor)); } + // ── OSC 7501 program status ──────────────────────────────────────────────── + // Runtime-only: never persisted. A relaunch builds a new VM, so everything here — + // including the AlertDetector stand-down — resets naturally on restart / wake. + + private readonly ProgramStatusRecords _programStatus = new(); + private ProgramStatusSummary _programSummary = ProgramStatusSummary.Empty; + private DateTime _lastProgramToastUtc = DateTime.MinValue; + private int _programStatusVersion; + + /// + /// Minimum gap between two program-status toasts for one session. The spec asks terminals + /// to rate-limit external effects; a program flipping blocked → working → blocked must not + /// produce a balloon per flip. + /// + internal static readonly TimeSpan ProgramStatusToastInterval = TimeSpan.FromSeconds(5); + + /// + /// True once the program has sent any valid OSC 7501 report. From then on the reported + /// state is authoritative and 's heuristic stands down. + /// + public bool HasProgramStatus { get; private set; } + + /// The aggregate reported state, or null when no records are held. + public ProgramState? AgentState => _programSummary.State; + + /// The program reports it is working (the aggregate state is working). + public bool IsWorking => _programSummary.State == ProgramState.Working; + + /// 0–100 while working with a known progress; null when indeterminate or not working. + public int? WorkProgress => IsWorking ? _programSummary.Progress : null; + + /// The program's decoded msg for the winning record, already sanitised. + public string? ProgramStatusMessage => _programSummary.Message; + + /// + /// One notification for , , + /// and — same reasoning as + /// : one row repaint per change, not one per property. + /// + public int ProgramStatusVersion => _programStatusVersion; + + /// + /// Applies one raw OSC 7501 payload. Invalid reports are ignored entirely (and do not + /// count as "the program speaks 7501"). Returns a toast-worthy notice when the session + /// just entered an attention state (blocked / done / error) and the per-session rate + /// limit allows one; otherwise null. + /// + public ProgramStatusNotice? ApplyProgramStatus(string payload, DateTime utcNow) + { + var report = ProgramStatusPayload.TryParse(payload); + if (report is null) return null; + + bool first = !HasProgramStatus; + if (first) + { + HasProgramStatus = true; + AlertDetector?.StandDown(); + } + _programStatus.Apply(report); + bool entered = SyncProgramStatus(force: first); + + if (!entered) return null; + if (utcNow - _lastProgramToastUtc < ProgramStatusToastInterval) return null; + _lastProgramToastUtc = utcNow; + return new ProgramStatusNotice(AlertMessage); + } + + /// + /// The session's process exited, or a new shell prompt began (OSC 133 A). Drops the live + /// records per the spec's lifetime rule; done/error survive until acknowledged. + /// + public void DropTransientProgramStatus() + { + if (!HasProgramStatus) return; + if (_programStatus.DropTransient()) SyncProgramStatus(force: false); + } + + /// + /// Re-derives the session's indicators from the records. Returns true when the session + /// just ENTERED an attention state — the only moment worth a badge and a toast. A report + /// that merely restates the current state (a new progress value, a new msg) is not one. + /// + private bool SyncProgramStatus(bool force) + { + var prev = _programSummary; + var next = _programStatus.Summarize(); + if (!force && next == prev) return false; + _programSummary = next; + + bool blocked = next.State == ProgramState.Blocked; + IsWaitingForApproval = blocked && next.Kind == BlockedKind.Permission; + // question, auth, and a blocked report with no kind all mean "your turn to type". + IsWaitingForInput = blocked && next.Kind != BlockedKind.Permission; + + bool entered = next.WantsAttention + && (force || prev.State != next.State || prev.Kind != next.Kind); + if (entered) + { + NeedsAttention = true; + AlertMessage = DescribeProgramStatus(next); + } + else if (!next.WantsAttention) + { + // Working or idle again: the moment that needed the user has passed. + NeedsAttention = false; + AlertMessage = ""; + } + + _programStatusVersion++; + OnPropertyChanged(nameof(ProgramStatusVersion)); + return entered; + } + + /// Short one-liner for the badge tooltip / toast. msg is already sanitised. + internal static string DescribeProgramStatus(ProgramStatusSummary s) + { + string text = s.Message ?? (s.State, s.Kind) switch + { + (ProgramState.Blocked, BlockedKind.Permission) => "Needs permission", + (ProgramState.Blocked, BlockedKind.Auth) => "Sign-in required", + (ProgramState.Blocked, _) => "Waiting for your input", + (ProgramState.Done, _) => "Finished", + (ProgramState.Error, _) => "Failed", + _ => "", + }; + return text.Length > 100 ? text[..100] + "…" : text; + } + public void RaiseAlert(string message, AlertType alertType = AlertType.InputRequired) { + // A heuristic alert can already be queued on the dispatcher when the first OSC 7501 + // report lands; once the program reports its own state, the guess must not win. + if (HasProgramStatus) return; NeedsAttention = true; AlertMessage = message; IsWaitingForInput = alertType == AlertType.InputRequired; @@ -447,6 +578,19 @@ public Task ReloadGitInfoAsync() public void ClearAlert() { + if (HasProgramStatus) + { + // The user has seen the session (focus, keystroke). Clear the badge, and retire + // done/error records — they mean "a result you haven't looked at". The waiting + // dots and the working indicator are NOT cleared: they describe what the program + // is doing right now, and it will report the next state itself. Clearing them + // here would blank an orange "needs permission" dot on the first keystroke. + NeedsAttention = false; + AlertMessage = ""; + if (_programStatus.Acknowledge()) SyncProgramStatus(force: false); + return; + } + NeedsAttention = false; AlertMessage = ""; IsWaitingForInput = false; diff --git a/tests/CodeShellManager.Tests/ProgramStatusPayloadTests.cs b/tests/CodeShellManager.Tests/ProgramStatusPayloadTests.cs new file mode 100644 index 0000000..473fb90 --- /dev/null +++ b/tests/CodeShellManager.Tests/ProgramStatusPayloadTests.cs @@ -0,0 +1,322 @@ +using System; +using System.Linq; +using System.Text; +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// Tests for — the WPF-free parser behind OSC 7501 +/// (Program Status Protocol). Every report is untrusted terminal output, and the spec says a +/// report that breaks any rule is dropped WHOLE, so most hostile cases assert null. +/// +public class ProgramStatusPayloadTests +{ + private static string B64(string s) => Convert.ToBase64String(Encoding.UTF8.GetBytes(s)); + + // ---- query ---------------------------------------------------------------------------- + + [Theory] + [InlineData("?")] + [InlineData(" ? ")] + [InlineData("?:states=idle")] + public void IsQuery_recognises_the_feature_query(string payload) => + Assert.True(ProgramStatusPayload.IsQuery(payload)); + + [Theory] + [InlineData("state=idle")] + [InlineData("")] + [InlineData(null)] + public void IsQuery_rejects_reports(string? payload) => + Assert.False(ProgramStatusPayload.IsQuery(payload)); + + [Fact] + public void ProbeReply_is_OSC_7501_question_mark_with_ST() + { + Assert.Equal("\x1b]7501;?\x1b\\", ProgramStatusPayload.ProbeReply); + Assert.Null(ProgramStatusPayload.TryParse("?")); // a query is never a report + } + + [Fact] + public void ConPty_reply_is_the_probe_reply_as_win32_input_mode_key_events() + { + // Conhost drops plain OSC on the ConPTY input side; one key-down per character + // (ESC [ Vk ; Sc ; Uc ; Kd ; Cs ; Rc _) is delivered to the program as those chars. + string encoded = ProgramStatusPayload.ProbeReplyForConPty; + var events = encoded.Split('_', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(ProgramStatusPayload.ProbeReply.Length, events.Length); + Assert.Equal("\x1b[0;0;27;1;0;1", events[0]); // ESC + Assert.Equal("\x1b[0;0;93;1;0;1", events[1]); // ] + var decoded = new string(events.Select(e => (char)int.Parse(e.Split(';')[2])).ToArray()); + Assert.Equal(ProgramStatusPayload.ProbeReply, decoded); + Assert.DoesNotContain("\x1b]", encoded); // no raw OSC left to drop + } + + // ---- states --------------------------------------------------------------------------- + + [Theory] + [InlineData("idle", ProgramState.Idle)] + [InlineData("working", ProgramState.Working)] + [InlineData("done", ProgramState.Done)] + [InlineData("blocked", ProgramState.Blocked)] + [InlineData("error", ProgramState.Error)] + [InlineData("clear", ProgramState.Clear)] + public void Every_baseline_state_parses(string text, ProgramState expected) => + Assert.Equal(expected, ProgramStatusPayload.TryParse($"state={text}")!.State); + + [Theory] + [InlineData("")] + [InlineData("app=x")] + [InlineData("state=sleeping")] + [InlineData("state=Working")] // values are case-sensitive + [InlineData("state=")] + public void Missing_or_unknown_state_ignores_the_report(string payload) => + Assert.Null(ProgramStatusPayload.TryParse(payload)); + + [Fact] + public void Claude_Code_shaped_report_parses() + { + // Exactly the field order Claude Code 2.1.319 emits. + var r = ProgramStatusPayload.TryParse( + $"state=blocked:app=claude-code:kind=permission:progress=40:msg={B64("Run npm test?")}"); + Assert.NotNull(r); + Assert.Equal(ProgramState.Blocked, r!.State); + Assert.Equal(BlockedKind.Permission, r.Kind); + Assert.Equal(40, r.Progress); + Assert.Equal("claude-code", r.App); + Assert.Equal("Run npm test?", r.Message); + Assert.Null(r.Id); + } + + // ---- pair syntax ---------------------------------------------------------------------- + + [Fact] + public void Whitespace_around_pairs_is_trimmed() => + Assert.Equal(ProgramState.Working, + ProgramStatusPayload.TryParse(" state = working : progress=5 ")!.State); + + [Fact] + public void Repeated_key_last_wins() => + Assert.Equal(ProgramState.Done, + ProgramStatusPayload.TryParse("state=working:state=done")!.State); + + [Theory] + [InlineData("state=working:garbage")] // no '=' + [InlineData("state=working:=x")] // empty key + [InlineData("state=working:Bad=1")] // uppercase key + [InlineData("state=working:k3y=1")] // digit in key + [InlineData("state=working:abcdefghijklmnopq=1")] // 17-byte key + [InlineData("state=working:app=a b")] // space inside a value + [InlineData("state=working:app=a;b")] // ';' not in alphabet + public void Malformed_pairs_are_skipped_not_fatal(string payload) + { + var r = ProgramStatusPayload.TryParse(payload); + Assert.NotNull(r); + Assert.Equal(ProgramState.Working, r!.State); + Assert.Null(r.App); + } + + [Fact] + public void Malformed_state_pair_means_no_state() => + Assert.Null(ProgramStatusPayload.TryParse("state=work ing")); + + [Fact] + public void Unknown_keys_are_ignored() => + Assert.NotNull(ProgramStatusPayload.TryParse("state=idle:future=1:icon=x")); + + // ---- kind / progress ------------------------------------------------------------------ + + [Theory] + [InlineData("permission", BlockedKind.Permission)] + [InlineData("question", BlockedKind.Question)] + [InlineData("auth", BlockedKind.Auth)] + [InlineData("other", BlockedKind.None)] + public void Kind_parses_for_blocked(string kind, BlockedKind expected) => + Assert.Equal(expected, ProgramStatusPayload.TryParse($"state=blocked:kind={kind}")!.Kind); + + [Fact] + public void Kind_is_ignored_outside_blocked() => + Assert.Equal(BlockedKind.None, + ProgramStatusPayload.TryParse("state=working:kind=permission")!.Kind); + + [Theory] + [InlineData("0", 0)] + [InlineData("100", 100)] + [InlineData("040", 40)] + public void Progress_accepts_0_to_100(string text, int expected) => + Assert.Equal(expected, ProgramStatusPayload.TryParse($"state=working:progress={text}")!.Progress); + + [Theory] + [InlineData("101")] + [InlineData("-1")] + [InlineData("4.5")] + [InlineData("1000")] + [InlineData("")] + [InlineData("+5")] + public void Invalid_progress_is_indeterminate_not_fatal(string text) + { + var r = ProgramStatusPayload.TryParse($"state=working:progress={text}"); + Assert.NotNull(r); + Assert.Null(r!.Progress); + } + + [Theory] + [InlineData("idle")] + [InlineData("done")] + [InlineData("error")] + public void Progress_is_ignored_outside_working_and_blocked(string state) => + Assert.Null(ProgramStatusPayload.TryParse($"state={state}:progress=50")!.Progress); + + [Fact] + public void Blocked_carries_progress() => + Assert.Equal(70, ProgramStatusPayload.TryParse("state=blocked:progress=70")!.Progress); + + // ---- id / app ------------------------------------------------------------------------- + + [Theory] + [InlineData("build")] + [InlineData("build/test/unit")] + [InlineData("a/b/c/d/e/f/g/h")] // 8 levels + public void Valid_ids_parse(string id) => + Assert.Equal(id, ProgramStatusPayload.TryParse($"state=working:id={id}")!.Id); + + [Theory] + [InlineData("a/b/c/d/e/f/g/h/i")] // 9 levels + [InlineData("/build")] // empty segment + [InlineData("build/")] + [InlineData("a//b")] + [InlineData("")] + public void Invalid_id_ignores_the_report_rather_than_writing_root(string id) => + Assert.Null(ProgramStatusPayload.TryParse($"state=working:id={id}")); + + [Fact] + public void Id_segment_over_32_bytes_ignores_the_report() + { + Assert.NotNull(ProgramStatusPayload.TryParse($"state=idle:id={new string('a', 32)}")); + Assert.Null(ProgramStatusPayload.TryParse($"state=idle:id={new string('a', 33)}")); + } + + [Fact] + public void Id_over_128_bytes_ignores_the_report() + { + string ok = string.Join("/", Enumerable.Repeat(new string('a', 31), 4)); // 127 + string tooLong = ok + "aa"; // 129, last seg 33 + string tooLong2 = string.Join("/", Enumerable.Repeat(new string('a', 32), 4)) + "/a"; // 134 + Assert.NotNull(ProgramStatusPayload.TryParse($"state=idle:id={ok}")); + Assert.Null(ProgramStatusPayload.TryParse($"state=idle:id={tooLong}")); + Assert.Null(ProgramStatusPayload.TryParse($"state=idle:id={tooLong2}")); + } + + [Fact] + public void App_over_32_bytes_drops_the_report() + { + Assert.Equal(new string('x', 32), + ProgramStatusPayload.TryParse($"state=idle:app={new string('x', 32)}")!.App); + Assert.Null(ProgramStatusPayload.TryParse($"state=idle:app={new string('x', 33)}")); + } + + // ---- base64 text ---------------------------------------------------------------------- + + [Fact] + public void Msg_and_title_decode_utf8() + { + var r = ProgramStatusPayload.TryParse($"state=done:title={B64("Café ✓")}:msg={B64("Tests passed – 42")}"); + Assert.Equal("Café ✓", r!.Title); + Assert.Equal("Tests passed – 42", r.Message); + } + + [Theory] + [InlineData("SGk")] // "Hi" unpadded + [InlineData("SGk=")] // padded + public void Base64_padding_is_optional(string b64) => + Assert.Equal("Hi", ProgramStatusPayload.TryParse($"state=done:msg={b64}")!.Message); + + [Theory] + [InlineData("S")] // length % 4 == 1 can never be base64 + [InlineData("SGk==")] // too much padding + [InlineData("S=Gk")] // padding in the middle + [InlineData("SG-k")] // url-safe alphabet is not standard base64 + [InlineData("SG_k")] + public void Invalid_base64_drops_the_report(string b64) => + Assert.Null(ProgramStatusPayload.TryParse($"state=done:msg={b64}")); + + [Theory] + [InlineData("line1\nline2")] + [InlineData("carriage\rreturn")] + [InlineData("esc \u001b[31m red")] + [InlineData("bell\u0007")] + [InlineData("del\u007f")] + [InlineData("c1\u009b")] + [InlineData("nul\0")] + public void Control_characters_in_decoded_text_drop_the_report(string text) + { + Assert.Null(ProgramStatusPayload.TryParse($"state=done:msg={B64(text)}")); + Assert.Null(ProgramStatusPayload.TryParse($"state=done:title={B64(text)}")); + } + + [Fact] + public void Invalid_utf8_drops_the_report() + { + string b64 = Convert.ToBase64String(new byte[] { 0x48, 0xff, 0xfe }); + Assert.Null(ProgramStatusPayload.TryParse($"state=done:msg={b64}")); + } + + [Fact] + public void Bidi_and_invisible_format_characters_are_neutralised() + { + // U+202E RIGHT-TO-LEFT OVERRIDE, U+200B ZERO WIDTH SPACE, U+2066 LRI. + var r = ProgramStatusPayload.TryParse($"state=done:msg={B64("ok‮txt.exe​⁦")}"); + Assert.Equal("oktxt.exe", r!.Message); + } + + [Fact] + public void Title_decoded_limit_is_192_bytes() + { + Assert.NotNull(ProgramStatusPayload.TryParse($"state=idle:title={B64(new string('t', 192))}")); + Assert.Null(ProgramStatusPayload.TryParse($"state=idle:title={B64(new string('t', 193))}")); + } + + [Fact] + public void Title_decoded_limit_counts_bytes_not_chars() + { + // 97 × "é" = 194 UTF-8 bytes, only 97 chars. + Assert.Null(ProgramStatusPayload.TryParse($"state=idle:title={B64(new string('é', 97))}")); + } + + [Fact] + public void Msg_decoded_limit_is_2048_bytes() + { + Assert.NotNull(ProgramStatusPayload.TryParse($"state=idle:msg={B64(new string('m', 2048))}")); + Assert.Null(ProgramStatusPayload.TryParse($"state=idle:msg={B64(new string('m', 2049))}")); + } + + [Fact] + public void Encoded_limits_are_enforced_before_decoding() + { + // 260 chars of valid-looking base64 for title (cap 256 encoded). + Assert.Null(ProgramStatusPayload.TryParse($"state=idle:title={new string('A', 260)}")); + Assert.Null(ProgramStatusPayload.TryParse($"state=idle:msg={new string('A', 2736)}")); + } + + [Fact] + public void Whole_sequence_over_4096_bytes_is_dropped() + { + // Padding with unknown keys keeps every individual field legal, so only the overall + // cap can reject this. + string filler = string.Concat(Enumerable.Repeat(":pad=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", 100)); + string payload = "state=working" + filler; + Assert.True(payload.Length > 4096); + Assert.Null(ProgramStatusPayload.TryParse(payload)); + + string fits = "state=working" + filler[..(4096 - 9 - "state=working".Length)]; + Assert.NotNull(ProgramStatusPayload.TryParse(fits.TrimEnd(':'))); + } + + [Fact] + public void A_bad_field_poisons_the_whole_report_even_with_a_valid_state() + { + // Nothing from a discarded report is applied — not even its state. + Assert.Null(ProgramStatusPayload.TryParse($"state=blocked:kind=permission:msg={B64("a\nb")}")); + } +} diff --git a/tests/CodeShellManager.Tests/ProgramStatusRecordsTests.cs b/tests/CodeShellManager.Tests/ProgramStatusRecordsTests.cs new file mode 100644 index 0000000..999fc52 --- /dev/null +++ b/tests/CodeShellManager.Tests/ProgramStatusRecordsTests.cs @@ -0,0 +1,190 @@ +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// Tests for — the OSC 7501 record tree: replacement, +/// clear semantics, eviction, lifetime and the aggregation precedence +/// (blocked > working > error > done > idle). +/// +public class ProgramStatusRecordsTests +{ + private static ProgramStatusReport R(ProgramState s, string? id = null, int? progress = null, + BlockedKind kind = BlockedKind.None, string? app = null, string? msg = null) => + new(s, kind, progress, app, id, null, msg); + + [Fact] + public void Empty_store_summarises_to_no_state() + { + var store = new ProgramStatusRecords(); + Assert.Null(store.Summarize().State); + Assert.False(store.Summarize().WantsAttention); + } + + [Fact] + public void Each_report_fully_replaces_its_record() + { + var store = new ProgramStatusRecords(); + store.Apply(R(ProgramState.Working, progress: 40, msg: "building")); + store.Apply(R(ProgramState.Working)); + var s = store.Summarize(); + Assert.Equal(ProgramState.Working, s.State); + Assert.Null(s.Progress); // not carried over + Assert.Null(s.Message); + Assert.Equal(1, store.Count); + } + + [Fact] + public void Clear_without_id_removes_everything() + { + var store = new ProgramStatusRecords(); + store.Apply(R(ProgramState.Working)); + store.Apply(R(ProgramState.Working, id: "a")); + store.Apply(R(ProgramState.Done, id: "b/c")); + Assert.True(store.Apply(R(ProgramState.Clear))); + Assert.Equal(0, store.Count); + Assert.False(store.Apply(R(ProgramState.Clear))); // nothing left to change + } + + [Fact] + public void Clear_with_id_removes_the_record_and_its_children_only() + { + var store = new ProgramStatusRecords(); + store.Apply(R(ProgramState.Idle)); + store.Apply(R(ProgramState.Working, id: "build")); + store.Apply(R(ProgramState.Working, id: "build/test")); + store.Apply(R(ProgramState.Working, id: "build/test/unit")); + store.Apply(R(ProgramState.Working, id: "buildx")); // sibling sharing a prefix + store.Apply(R(ProgramState.Clear, id: "build")); + Assert.Equal(2, store.Count); // root + buildx + Assert.Equal(ProgramState.Working, store.Summarize().State); + } + + [Fact] + public void Clear_of_unknown_id_reports_no_change() + { + var store = new ProgramStatusRecords(); + store.Apply(R(ProgramState.Working)); + Assert.False(store.Apply(R(ProgramState.Clear, id: "nope"))); + } + + [Fact] + public void Store_caps_at_256_and_evicts_least_recently_updated() + { + var store = new ProgramStatusRecords(); + for (int i = 0; i < ProgramStatusRecords.MaxRecords; i++) + store.Apply(R(ProgramState.Working, id: $"r{i}")); + // Touch r0 so r1 becomes the oldest. + store.Apply(R(ProgramState.Blocked, id: "r0")); + store.Apply(R(ProgramState.Working, id: "new")); + + Assert.Equal(ProgramStatusRecords.MaxRecords, store.Count); + // r1 was evicted: clearing it changes nothing. r0 survived. + Assert.False(store.Apply(R(ProgramState.Clear, id: "r1"))); + Assert.True(store.Apply(R(ProgramState.Clear, id: "r0"))); + } + + [Theory] + [InlineData(ProgramState.Blocked, ProgramState.Working, ProgramState.Blocked)] + [InlineData(ProgramState.Working, ProgramState.Error, ProgramState.Working)] + [InlineData(ProgramState.Working, ProgramState.Done, ProgramState.Working)] + [InlineData(ProgramState.Error, ProgramState.Done, ProgramState.Error)] + [InlineData(ProgramState.Done, ProgramState.Idle, ProgramState.Done)] + [InlineData(ProgramState.Blocked, ProgramState.Error, ProgramState.Blocked)] + public void Precedence_is_blocked_working_error_done_idle(ProgramState a, ProgramState b, ProgramState expected) + { + // Both orders of arrival, both root/child placements: precedence, not recency, decides. + foreach (var (first, second) in new[] { (a, b), (b, a) }) + { + var store = new ProgramStatusRecords(); + store.Apply(R(first, id: "x")); + store.Apply(R(second, id: "y")); + Assert.Equal(expected, store.Summarize().State); + + var rooted = new ProgramStatusRecords(); + rooted.Apply(R(first)); + rooted.Apply(R(second, id: "child")); + Assert.Equal(expected, rooted.Summarize().State); + } + } + + [Fact] + public void A_stale_done_child_never_hides_live_work() + { + var store = new ProgramStatusRecords(); + store.Apply(R(ProgramState.Done, id: "old")); // persists until acknowledged + store.Apply(R(ProgramState.Working, progress: 10)); + Assert.Equal(ProgramState.Working, store.Summarize().State); + Assert.Equal(10, store.Summarize().Progress); + } + + [Fact] + public void Ties_prefer_the_root_then_the_most_recent() + { + var store = new ProgramStatusRecords(); + store.Apply(R(ProgramState.Working, progress: 10)); + store.Apply(R(ProgramState.Working, id: "a", progress: 20)); + Assert.Equal(10, store.Summarize().Progress); // root wins the tie + + var children = new ProgramStatusRecords(); + children.Apply(R(ProgramState.Working, id: "a", progress: 20)); + children.Apply(R(ProgramState.Working, id: "b", progress: 30)); + Assert.Equal(30, children.Summarize().Progress); // most recent child + } + + [Fact] + public void Summary_carries_kind_and_message_of_the_winner() + { + var store = new ProgramStatusRecords(); + store.Apply(R(ProgramState.Working, msg: "root")); + store.Apply(R(ProgramState.Blocked, id: "deploy/eu", kind: BlockedKind.Auth, msg: "login")); + var s = store.Summarize(); + Assert.Equal(BlockedKind.Auth, s.Kind); + Assert.Equal("login", s.Message); + Assert.True(s.WantsAttention); + } + + [Fact] + public void App_is_inherited_from_the_nearest_ancestor() + { + var store = new ProgramStatusRecords(); + store.Apply(R(ProgramState.Working, app: "cargo")); + store.Apply(R(ProgramState.Working, id: "build", app: "rustc")); + store.Apply(R(ProgramState.Working, id: "build/test/unit")); + store.Apply(R(ProgramState.Working, id: "lint")); + Assert.Equal("rustc", store.EffectiveApp("build/test/unit")); // skips missing build/test + Assert.Equal("cargo", store.EffectiveApp("lint")); + Assert.Equal("cargo", store.EffectiveApp(null)); + } + + [Fact] + public void Process_exit_drops_live_records_and_keeps_results() + { + var store = new ProgramStatusRecords(); + store.Apply(R(ProgramState.Working)); + store.Apply(R(ProgramState.Blocked, id: "b")); + store.Apply(R(ProgramState.Idle, id: "i")); + store.Apply(R(ProgramState.Done, id: "d")); + store.Apply(R(ProgramState.Error, id: "e")); + + Assert.True(store.DropTransient()); + Assert.Equal(2, store.Count); + Assert.Equal(ProgramState.Error, store.Summarize().State); + Assert.False(store.DropTransient()); // idempotent + } + + [Fact] + public void Acknowledge_retires_done_and_error_only() + { + var store = new ProgramStatusRecords(); + store.Apply(R(ProgramState.Done)); + store.Apply(R(ProgramState.Error, id: "e")); + store.Apply(R(ProgramState.Blocked, id: "b", kind: BlockedKind.Permission)); + + Assert.True(store.Acknowledge()); + Assert.Equal(1, store.Count); + Assert.Equal(ProgramState.Blocked, store.Summarize().State); + Assert.False(store.Acknowledge()); + } +} diff --git a/tests/CodeShellManager.Tests/SessionViewModelProgramStatusTests.cs b/tests/CodeShellManager.Tests/SessionViewModelProgramStatusTests.cs new file mode 100644 index 0000000..4cf9bfa --- /dev/null +++ b/tests/CodeShellManager.Tests/SessionViewModelProgramStatusTests.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Text; +using CodeShellManager.Models; +using CodeShellManager.Services; +using CodeShellManager.ViewModels; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// The OSC 7501 → session-indicator mapping on : +/// working → IsWorking/WorkProgress; blocked+permission → approval (orange); +/// blocked+question/auth/none → input (green); done/error → NeedsAttention; idle/clear → nothing. +/// Plus the fallback rule: the heuristic AlertDetector stands down once 7501 is seen. +/// +public class SessionViewModelProgramStatusTests +{ + private static readonly DateTime T0 = new(2026, 11, 6, 12, 0, 0, DateTimeKind.Utc); + + private static SessionViewModel MakeVm() => + // Ssh: no git spawns, no watcher — hermetic (see SessionViewModelGitInfoTests). + new(new ShellSession { Id = "s", Name = "s", Kind = SessionKind.Ssh, SshHost = "example.invalid" }); + + private static string B64(string s) => Convert.ToBase64String(Encoding.UTF8.GetBytes(s)); + + [Fact] + public void Working_with_progress_sets_the_working_state() + { + var vm = MakeVm(); + vm.ApplyProgramStatus("state=working:progress=40", T0); + Assert.True(vm.HasProgramStatus); + Assert.True(vm.IsWorking); + Assert.Equal(40, vm.WorkProgress); + Assert.False(vm.NeedsAttention); + Assert.False(vm.IsWaitingForInput); + Assert.False(vm.IsWaitingForApproval); + } + + [Fact] + public void Working_without_progress_is_indeterminate() + { + var vm = MakeVm(); + vm.ApplyProgramStatus("state=working", T0); + Assert.True(vm.IsWorking); + Assert.Null(vm.WorkProgress); + } + + [Fact] + public void Blocked_permission_is_approval_and_raises_a_notice() + { + var vm = MakeVm(); + var notice = vm.ApplyProgramStatus($"state=blocked:kind=permission:msg={B64("Run rm?")}", T0); + Assert.True(vm.IsWaitingForApproval); + Assert.False(vm.IsWaitingForInput); + Assert.True(vm.NeedsAttention); + Assert.Equal("Run rm?", vm.AlertMessage); + Assert.Equal("Run rm?", notice!.Message); + } + + [Theory] + [InlineData("state=blocked:kind=question")] + [InlineData("state=blocked:kind=auth")] + [InlineData("state=blocked")] + public void Other_blocked_kinds_are_input(string payload) + { + var vm = MakeVm(); + vm.ApplyProgramStatus(payload, T0); + Assert.True(vm.IsWaitingForInput); + Assert.False(vm.IsWaitingForApproval); + Assert.True(vm.NeedsAttention); + } + + [Theory] + [InlineData("state=done", "Finished")] + [InlineData("state=error", "Failed")] + public void Done_and_error_need_attention(string payload, string message) + { + var vm = MakeVm(); + var notice = vm.ApplyProgramStatus(payload, T0); + Assert.True(vm.NeedsAttention); + Assert.Equal(message, notice!.Message); + Assert.False(vm.IsWorking); + } + + [Fact] + public void Idle_and_clear_clear_the_indicators() + { + var vm = MakeVm(); + vm.ApplyProgramStatus("state=blocked:kind=permission", T0); + vm.ApplyProgramStatus("state=idle", T0); + Assert.False(vm.NeedsAttention); + Assert.False(vm.IsWaitingForApproval); + + vm.ApplyProgramStatus("state=working", T0); + vm.ApplyProgramStatus("state=clear", T0); + Assert.False(vm.IsWorking); + Assert.Null(vm.AgentState); + } + + [Fact] + public void Restating_the_same_state_does_not_re_raise_attention_or_toast() + { + var vm = MakeVm(); + Assert.NotNull(vm.ApplyProgramStatus("state=blocked:kind=permission:progress=10", T0)); + vm.ClearAlert(); // user looked + Assert.False(vm.NeedsAttention); + + // Same state, new progress: not a new attention moment. + Assert.Null(vm.ApplyProgramStatus("state=blocked:kind=permission:progress=20", T0.AddMinutes(1))); + Assert.False(vm.NeedsAttention); + Assert.True(vm.IsWaitingForApproval); // the dot is still truthful + } + + [Fact] + public void Toasts_are_rate_limited_per_session() + { + var vm = MakeVm(); + Assert.NotNull(vm.ApplyProgramStatus("state=blocked:kind=permission", T0)); + vm.ApplyProgramStatus("state=working", T0.AddSeconds(1)); + // Re-entered attention 2s after the last toast: badge yes, toast no. + Assert.Null(vm.ApplyProgramStatus("state=blocked:kind=permission", T0.AddSeconds(2))); + Assert.True(vm.NeedsAttention); + + vm.ApplyProgramStatus("state=working", T0.AddSeconds(3)); + Assert.NotNull(vm.ApplyProgramStatus("state=done", T0 + SessionViewModel.ProgramStatusToastInterval)); + } + + [Fact] + public void Invalid_reports_are_ignored_and_do_not_claim_the_session() + { + var vm = MakeVm(); + Assert.Null(vm.ApplyProgramStatus("state=bogus", T0)); + Assert.Null(vm.ApplyProgramStatus($"state=done:msg={B64("a\nb")}", T0)); + Assert.False(vm.HasProgramStatus); + Assert.False(vm.NeedsAttention); + } + + [Fact] + public void Heuristic_alerts_stand_down_once_7501_is_seen() + { + var vm = MakeVm(); + vm.AlertDetector = new AlertDetector(vm.Id, "s"); + vm.ApplyProgramStatus("state=working", T0); + + Assert.True(vm.AlertDetector.IsStoodDown); + vm.RaiseAlert("Do you want to proceed?", AlertType.ToolApproval); // a queued guess + Assert.False(vm.IsWaitingForApproval); + Assert.False(vm.NeedsAttention); + Assert.True(vm.IsWorking); + } + + [Fact] + public void First_report_overrides_a_heuristic_state() + { + var vm = MakeVm(); + vm.RaiseAlert("❯", AlertType.InputRequired); + Assert.True(vm.IsWaitingForInput); + vm.ApplyProgramStatus("state=working", T0); + Assert.False(vm.IsWaitingForInput); + Assert.False(vm.NeedsAttention); + } + + [Fact] + public void Sessions_without_7501_keep_todays_alert_behaviour() + { + var vm = MakeVm(); + vm.RaiseAlert("Allow?", AlertType.ToolApproval); + Assert.True(vm.IsWaitingForApproval); + vm.ClearAlert(); + Assert.False(vm.IsWaitingForApproval); + Assert.False(vm.NeedsAttention); + } + + [Fact] + public void User_interaction_acknowledges_done_but_keeps_live_states() + { + var vm = MakeVm(); + vm.ApplyProgramStatus("state=done", T0); + vm.ClearAlert(); + Assert.False(vm.NeedsAttention); + Assert.Null(vm.AgentState); // done retired: the user has seen it + + vm.ApplyProgramStatus("state=blocked:kind=permission", T0); + vm.ClearAlert(); // a keystroke while the prompt is up + Assert.False(vm.NeedsAttention); // badge acknowledged… + Assert.True(vm.IsWaitingForApproval); // …but the program is still blocked + + vm.ApplyProgramStatus("state=working", T0); + vm.ClearAlert(); + Assert.True(vm.IsWorking); + } + + [Fact] + public void Process_exit_drops_working_and_blocked_but_keeps_done() + { + var vm = MakeVm(); + vm.ApplyProgramStatus("state=working", T0); + vm.DropTransientProgramStatus(); + Assert.False(vm.IsWorking); + + vm.ApplyProgramStatus("state=done", T0); + vm.ApplyProgramStatus("state=blocked:id=sub", T0); + vm.DropTransientProgramStatus(); + Assert.Equal(ProgramState.Done, vm.AgentState); + } + + [Fact] + public void State_changes_raise_one_ProgramStatusVersion_notification() + { + var vm = MakeVm(); + vm.ApplyProgramStatus("state=working:progress=1", T0); + var seen = new List(); + vm.PropertyChanged += (_, e) => seen.Add(e.PropertyName ?? ""); + + vm.ApplyProgramStatus("state=working:progress=2", T0); + Assert.Equal(new[] { nameof(SessionViewModel.ProgramStatusVersion) }, seen); + + seen.Clear(); + vm.ApplyProgramStatus("state=working:progress=2", T0); // identical: nothing at all + Assert.Empty(seen); + } +} From e8b366ed5dfa1a67f0ec9f2918e5c8ff6830d803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Aslo-=C3=98stergaard?= Date: Fri, 6 Nov 2026 17:20:37 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix(status):=20OSC=207501=20review=20?= =?UTF-8?q?=E2=80=94=20probe=20reply=20off=20by=20default,=20per-record=20?= =?UTF-8?q?attention,=20re-arm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for the OSC 7501 program-status work (#154 Part 1). - Probe reply OFF by default (AppSettings.AnswerProgramStatusProbe). Under the inbox ConPTY it always loses to conhost's own DA1 answer and only reaches the program as late stdin starting with ESC. When enabled, ProgramStatusProbeGate requires an exact "?" query, win32-input-mode requested by this session's ConPTY (ESC[?9001h/l tracked on output), 1 reply per 5s, 3 per session; the write goes to the thread pool. Reports are always parsed. - Attention judged per record: a root done/error/blocked badges and toasts even while a working child holds the aggregate; the badge clears only when no record is in an attention state. - Re-arm the heuristic when the store empties via clear / prompt start / exit, plus a sidebar "Clear status" item. Not on acknowledge. - Exit race: working/blocked/idle reports after PTY exit are ignored. - Parser: id segments and app use [A-Za-z0-9_.+-]{1,32} (bad app = absent), a key over 16 bytes drops the report, format chars stripped by rune (tag block), U+2028/U+2029 rejected. - Working pulse runs only while visible (IsVisibleChanged, Loaded/Unloaded, minimize). - Acknowledge on real key events (onKey) or focus, not on every onData; allocation-free per-keystroke guard. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NsYm9iLc2aRnRDfV2uZRQX --- CLAUDE.md | 97 ++++++++----- README.md | 2 +- docs/shell-integration.md | 21 +-- src/CodeShellManager/Assets/terminal-init.js | 16 +-- .../MainWindow.ProgramStatus.cs | 83 ++++++++---- src/CodeShellManager/MainWindow.xaml.cs | 16 ++- src/CodeShellManager/Models/AppState.cs | 10 ++ .../Services/AlertDetector.cs | 14 +- .../Services/ProgramStatusPayload.cs | 58 +++++--- .../Services/ProgramStatusProbeGate.cs | 62 +++++++++ .../Services/ProgramStatusRecords.cs | 47 ++++++- .../Terminal/TerminalBridge.cs | 27 +++- .../ViewModels/MainViewModel.cs | 33 ++++- .../ViewModels/SessionViewModel.cs | 104 +++++++++++--- .../ProgramStatusPayloadTests.cs | 62 ++++++++- .../ProgramStatusProbeGateTests.cs | 73 ++++++++++ .../ProgramStatusRecordsTests.cs | 43 ++++++ .../SessionViewModelProgramStatusTests.cs | 127 ++++++++++++++++++ 18 files changed, 771 insertions(+), 124 deletions(-) create mode 100644 src/CodeShellManager/Services/ProgramStatusProbeGate.cs create mode 100644 tests/CodeShellManager.Tests/ProgramStatusProbeGateTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 4613a16..72af2df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,7 @@ PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js) | `ShellIntegrationPayload` | WPF-free validation for OSC 9001. Every field is untrusted: colour is strict hex, dirty is an allowlist, title and branch strip control characters and are length-capped (`MaxTitleLength` 80, `MaxBranchLength` 200) | | `AlertDetector` | Pattern matching for Claude prompts/approvals. `StandDown()` silences it once a session speaks OSC 7501 | | `ProgramStatusPayload` | WPF-free OSC 7501 report parser/validator + the probe reply (win32-input-mode encoded for ConPTY). See "Program status (OSC 7501)" | +| `ProgramStatusProbeGate` | Per-session gate on answering the 7501 probe: setting on (default off), win32-input-mode requested, 1 per 5s, 3 per session | | `ProgramStatusRecords` | WPF-free OSC 7501 record tree: replace, `clear`, 256-cap eviction, exit lifetime, acknowledge, and the blocked > working > error > done > idle aggregate | | `CommandPresetsService` | Launch presets + in-session shortcuts | | `ClaudeSessionService` | Detects `claude` invocations; finds last `--resume` session id under `~/.claude/projects/` | @@ -661,24 +662,38 @@ one, child records per background task, and `state=clear` on exit — **but only startup query `OSC 7501 ; ?` is answered**. Unanswered, it logs `probe: no reply to OSC 7501 ; ?` and never reports. -**Pipeline.** `terminal-init.js` registers `registerOscHandler(7501, …)`: a `?` query posts +**Claude Code does not report under the inbox ConPTY yet; programs that report without probing +work.** The reply cannot win the race (see the measured subsection below), so answering the probe +is **off by default**. + +**Pipeline.** `terminal-init.js` registers `registerOscHandler(7501, …)`: an exact `?` posts `programStatusProbe`, a report posts `programStatus` with the raw payload (pre-capped at 4096). It also observes OSC 133 `A` and posts `promptStart` (returns `false` — observe only). -`TerminalBridge` answers the probe itself with fixed bytes (`ProgramStatusPayload.ProbeReplyForConPty`) -— app-authored, so a raw PTY write is correct, and it deliberately does **not** raise -`UserInput` — and raises `ProgramStatusReceived` / `PromptStarted`. `MainViewModel.ApplyProgramStatus` +Reports are always parsed. The probe is answered only when `AppSettings.AnswerProgramStatusProbe` +is true — no UI; set `"AnswerProgramStatusProbe": true` under `Settings` in `state.json` for +experiments — and even then `Services/ProgramStatusProbeGate` requires that this session's +ConPTY has requested win32-input-mode (`ESC[?9001h` seen on output, `?9001l` turns it off; +conhost sends `h` as its first bytes), at most one reply per 5s and 3 per session. The reply is +fixed bytes (`ProgramStatusPayload.ProbeReplyForConPty`), written from the thread pool — app-authored, +so a raw PTY write is correct, and it deliberately does **not** raise `UserInput`. Why so guarded: +the reply always arrives *after* conhost's DA1 answer, i.e. as late unsolicited stdin starting +with ESC, which a TUI can take for an Escape keypress; to a program that never asked it is junk +input. `TerminalBridge` raises `ProgramStatusReceived` / `PromptStarted`. `MainViewModel.ApplyProgramStatus` → `SessionViewModel.ApplyProgramStatus` parses (`Services/ProgramStatusPayload`), stores (`Services/ProgramStatusRecords`) and maps; the alert count and a toast are handled in `MainViewModel`. All WPF-free logic is tested in `ProgramStatusPayloadTests`, -`ProgramStatusRecordsTests` and `SessionViewModelProgramStatusTests`. Nothing is persisted. +`ProgramStatusRecordsTests`, `ProgramStatusProbeGateTests` and `SessionViewModelProgramStatusTests`. +Nothing is persisted. -**Validation is the spec's, and strict.** Keys `[a-z]{1,16}`, values `[A-Za-z0-9_.,+/=-]*`, +**Validation is the spec's, and strict.** Keys `[a-z]+`, values `[A-Za-z0-9_.,+/=-]*`, malformed pairs skipped, last repeat wins. A report is dropped *whole* on a missing/unknown -state, an invalid id (it is not written to the root instead), a limit (sequence 4096, app 32, -id 128/32 per segment/8 deep, title 256→192, msg 2732→2048 bytes), bad base64, invalid UTF-8, -or any control character in decoded text. Unicode format characters (bidi overrides, -zero-width) are stripped, not rejected — the spec asks for them to be neutralised. An -out-of-range `progress` is treated as indeterminate, not as a reason to drop the report. +state, an invalid id (segments `[A-Za-z0-9_.+-]{1,32}`, ≤8 deep, ≤128 bytes — never written to +the root instead), a limit (sequence 4096, key 16, title 256→192, msg 2732→2048 bytes), bad +base64, invalid UTF-8, or any control character or line/paragraph separator (U+2028/U+2029) in +decoded text. Unicode format characters (bidi overrides, zero-width, the supplementary tag +block U+E0001/U+E0020–E007F) are stripped by rune, not rejected — the spec asks for them to be +neutralised. An `app` that isn't `[A-Za-z0-9_.+-]{1,32}` and an out-of-range `progress` are +treated as absent, not as reasons to drop the report. **Records and precedence.** One record per id (root = no id); each report replaces its record wholesale; `clear` removes the id and its children, or everything without an id; 256 records, @@ -692,30 +707,49 @@ would let a stale child result hide live work forever. | `working` (+`progress`) | `IsWorking` / `WorkProgress` → pulsing `⟳ working · 40%` in the sidebar row and `⟳ 40%` on the toolbar (accent blue, 10 fps opacity pulse, chrome only — HwndHost) | | `blocked` + `permission` | `IsWaitingForApproval` (orange) | | `blocked` + `question` / `auth` / no kind | `IsWaitingForInput` (green) | -| `done` / `error` | `NeedsAttention` badge | +| any record **entering** `blocked` / `done` / `error` | `NeedsAttention` badge + toast | | `idle` / none | indicators cleared | -The badge and toast fire only on **entering** blocked/done/error (a new state or kind), never -on a report that restates the current one with a new progress or msg; toasts are further -limited to one per session per `ProgramStatusToastInterval` (5s). Leaving for working/idle -clears the badge. +**Attention is judged per record, not from the aggregate.** A long-lived working child (a +background task) outranks the root, so the root's turn-end `done` would never change the +aggregate — judged on the aggregate, the user got no badge. So `ProgramStatusRecords.Apply` +reports whether *that record* entered blocked/done/error (new, or a different state or kind); +a report restating the same state with new progress or msg does not. The badge is cleared only +once **no** record is in an attention state, so child work cannot wipe an unseen result, while +the working indicator keeps showing the child. Toasts are further limited to one per session +per `ProgramStatusToastInterval` (5s). **Fallback rule.** The first *valid* report sets `HasProgramStatus` and calls -`AlertDetector.StandDown()` — the regex heuristic stops for that session's lifetime (same -pattern as `_gitOverriddenByOsc`), and `RaiseAlert` plus the queued `AlertRaised` handler both -ignore late heuristic alerts. A relaunch builds a new VM, so restart/wake re-arm it. Sessions -that never send 7501 behave exactly as before. - -**User interaction acknowledges, it does not clear.** For a 7501 session `ClearAlert` -(keystroke via `NotifyUserInteracted`, sidebar focus) clears the badge and retires `done`/`error` -records — "a result you haven't seen" — but leaves the waiting dots and the working indicator -alone: they describe what the program is doing now, and it will report its next state itself. -Clearing them would blank an orange "needs permission" dot on the first keystroke. +`AlertDetector.StandDown()` (same pattern as `_gitOverriddenByOsc`); `RaiseAlert` and the queued +`AlertRaised` handler both ignore late heuristic alerts. The heuristic is **re-armed** +(`AlertDetector.Rearm()`, `HasProgramStatus = false`) when the store becomes empty through a +`clear`, through `DropTransient` (prompt start / exit) leaving nothing, or through the sidebar +right-click **"Clear status"** item (shown while program status is present) — the user's way out +of a status that got stuck because a program died mid-turn. It is deliberately *not* re-armed +when acknowledging empties the store: a Claude session whose `done` was just seen would have the +regex guess at its prompt again. A time-based "stale working" rule was considered and rejected — +Claude thinking for minutes without a new report is normal, and any timeout would fight it. +Sessions that never send 7501 behave exactly as before. + +**User interaction acknowledges, it does not clear.** Only a *real* key event (`KeyboardInput`, +xterm's `onKey`) or focusing the session acknowledges — not `UserInput`, which is every `onData` +and so includes xterm's own automatic replies (DA, cursor reports, focus in/out); for a 7501 +session `UserInput` no longer reaches `NotifyUserInteracted` at all. Acknowledging clears the badge +and retires `done`/`error` records but leaves the waiting dots and the working indicator alone: +they describe what the program is doing now, and it will report its next state itself. The +per-keystroke guard `HasUnacknowledgedProgramStatus` is allocation-free and false on almost +every key. **Lifetime.** PTY exit and OSC 133 `A` call `DropTransient`: working, blocked and idle records go; -done and error stay until acknowledged. A full reset (`RIS`) is not tracked. Note the PTY is -usually `pwsh -NoExit -Command claude`, so "process exit" is the wrapper's — Claude's own -`state=clear` on exit is what normally retires its records. +done and error stay until acknowledged. After the PTY has exited, working/blocked/idle reports +still in flight (web messages queued behind the exit) are ignored, so a late `working` cannot +stick forever; results and clears are still honoured. A full reset (`RIS`) is not tracked. Note +the PTY is usually `pwsh -NoExit -Command claude`, so "process exit" is the wrapper's — Claude's +own `state=clear` on exit is what normally retires its records. + +**The pulse runs only while seen.** The indicator's animation starts and stops on +`IsVisibleChanged` (a filtered-out sidebar row, a pane not in the layout), Loaded/Unloaded, and +the window's minimize/restore. ### The probe reply cannot win under the inbox ConPTY (measured) @@ -732,8 +766,9 @@ app's own `PseudoTerminal` and against the real `claude`: query as a "no more replies" sentinel, so conhost's answer always arrives *before* ours can make the round trip through the page and back — Claude logs "no reply" and settles to off. -So with Claude 2.1.319 on the inbox ConPTY, **no reports arrive yet**; everything above is -correct and tested, and lights up as soon as either side moves. Paths, none taken here: +So with Claude 2.1.319 on the inbox ConPTY, **no reports arrive yet** — and a reply that cannot +be on time is only late stdin, which is why it is off by default. Everything else is tested and +lights up as soon as either side moves. Paths, none taken here: bundling a newer ConPTY (`Microsoft.Windows.Console.ConPTY`: `conpty.dll` + `OpenConsole.exe`) if it forwards OSC input and doesn't answer DA1 itself (unverified); or Claude Code accepting a late reply / an env opt-in. Pre-seeding the reply before the program asks was rejected — in a diff --git a/README.md b/README.md index ca72648..69d1cd7 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Spotted something out of date? Competitors move fast. [Open an issue](https://gi - **Full-text search** — all terminal output indexed to SQLite FTS5; instant search across every session, ever - **Per-project notepad** — collapsible 📝 notes panel on every terminal, auto-saved and searchable - **Alert detection** — detects when Claude is waiting for input or tool approval; green/orange dot indicators -- **Program status (OSC 7501)** — programs that report their own state via the [Program Status Protocol](https://www.superlogical.com/rex/docs/build/program-status) get a pulsing `⟳ working · 40%` indicator in the sidebar and toolbar, exact waiting-for-input / approval dots, and a badge when they finish or fail; sessions that don't report it keep the heuristic detection +- **Program status (OSC 7501)** — programs that report their own state via the [Program Status Protocol](https://www.superlogical.com/rex/docs/build/program-status) get a pulsing `⟳ working · 40%` indicator in the sidebar and toolbar, exact waiting-for-input / approval dots, and a badge when they finish or fail; sessions that don't report it keep the heuristic detection. (Claude Code does not report under the Windows inbox ConPTY yet — see [`docs/shell-integration.md`](docs/shell-integration.md).) - **Git status** — shows branch and dirty state in the sidebar per session - **Git tools on the terminal toolbar** — a chip like `⎇ main ● ↑2 ↓5` (branch, uncommitted changes, ahead/behind) on every repo session; its menu does Fetch, fast-forward-only Pull, Status, and Switch branch (worktree-aware). Deliberately read-mostly: no commit, push or merge, and nothing is ever typed into the terminal - **Open in VS Code / Explorer / PowerShell** — `{ }`, 📁 and `>_` on the terminal toolbar (and in the sidebar right-click menu) open the session's folder; VS Code appears when it is installed with `code` on PATH diff --git a/docs/shell-integration.md b/docs/shell-integration.md index fbb0ab6..5ebd67c 100644 --- a/docs/shell-integration.md +++ b/docs/shell-integration.md @@ -4,17 +4,18 @@ Programs running inside a CodeShellManager terminal can push session state up to > **Reporting working / waiting / done?** Use the standard **OSC 7501 Program Status Protocol** > ([spec](https://www.superlogical.com/rex/docs/build/program-status)) rather than OSC 9001. CSM -> answers the `OSC 7501 ; ? ST` feature query and maps reports onto the session: -> `working` (+`progress`) → a `⟳ working · 40%` indicator in the sidebar and toolbar; -> `blocked` + `kind=permission` → orange approval dot; other `blocked` → green input dot; -> `done` / `error` → "needs attention" badge and a rate-limited tray notification; `idle` / `clear` -> → indicators cleared. Once a session sends any valid 7501 report, CSM's own screen-scraping -> prompt detection stands down for that session. Status is runtime-only — never saved. +> maps reports onto the session: `working` (+`progress`) → a `⟳ working · 40%` indicator in the +> sidebar and toolbar; `blocked` + `kind=permission` → orange approval dot; other `blocked` → +> green input dot; a record entering `done` / `error` / `blocked` → "needs attention" badge and a +> rate-limited tray notification; `idle` / `clear` → indicators cleared. While a session holds any +> 7501 record, CSM's own screen-scraping prompt detection stands down for it ("Clear status" in +> the session's right-click menu hands it back). Status is runtime-only — never saved. > -> Windows note: the inbox ConPTY answers the `CSI c` device-attributes query itself, before CSM -> can reply to a `?` query sent just ahead of it, so a program that treats that answer as "no more -> replies are coming" will conclude CSM does not support 7501. Programs can always send reports -> without querying first — the spec makes detection optional. +> **Send reports without querying first.** CSM does not answer the `OSC 7501 ; ? ST` feature +> query by default: under the Windows inbox ConPTY a reply cannot arrive before conhost's own +> answer to the `CSI c` query programs send right after it, so it would only reach the program +> as late, unsolicited input. The spec makes detection optional; just report. (Claude Code +> probes and therefore does not report under the inbox ConPTY yet.) ## Wire format diff --git a/src/CodeShellManager/Assets/terminal-init.js b/src/CodeShellManager/Assets/terminal-init.js index 18e8b32..0765cd5 100644 --- a/src/CodeShellManager/Assets/terminal-init.js +++ b/src/CodeShellManager/Assets/terminal-init.js @@ -138,17 +138,17 @@ // ESC ] 7501 ; ? ST (feature query) // Parsing and validation are on the C# side (ProgramStatusPayload) — this only routes. // - // The query MUST be answered: Claude Code sends it at startup and, with no reply, never - // reports status. The host writes fixed reply bytes to the PTY (not via 'input': a reply - // is not the user typing). Posted synchronously from inside the parser so it is handled - // as early as possible — the program treats its device-attributes answer (CSI c, sent - // right after this query) as "no more replies coming". See CLAUDE.md "Program status - // (OSC 7501)": under the inbox ConPTY that race is currently lost, because conhost - // answers CSI c itself before the query ever reaches this page. + // Claude Code sends the query at startup and, with no reply, never reports status. The + // host decides whether to answer (OFF by default — AppSettings.AnswerProgramStatusProbe): + // under the inbox ConPTY the answer always loses to conhost's own CSI c reply, so it + // would only reach the program as late, unsolicited input. See CLAUDE.md "Program status + // (OSC 7501)". Reports are always forwarded. term.parser.registerOscHandler(7501, data => { try { const s = String(data); - if (s.trim().charAt(0) === '?') { + // Only an exact "?" is a query. Anything else starting with '?' is neither a query + // nor a report (keys are [a-z]) and is dropped by the host's parser. + if (s.trim() === '?') { window.chrome.webview.postMessage(JSON.stringify({ type: 'programStatusProbe' })); } else if (s.length <= 4096) { // Cheap pre-filter for the spec's 4096-byte sequence cap; the host re-checks bytes. diff --git a/src/CodeShellManager/MainWindow.ProgramStatus.cs b/src/CodeShellManager/MainWindow.ProgramStatus.cs index bc54903..1ef55f5 100644 --- a/src/CodeShellManager/MainWindow.ProgramStatus.cs +++ b/src/CodeShellManager/MainWindow.ProgramStatus.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Windows; using System.Windows.Controls; using System.Windows.Media; @@ -23,6 +24,12 @@ public partial class MainWindow // gains nothing from 60, and a fleet of working sessions must not cost a frame each. private static readonly DoubleAnimation s_workingPulse = CreateWorkingPulse(); + // Indicators currently in the visual tree (Loaded/Unloaded), so a minimize/restore can + // stop and restart their pulses, and the set currently pulsing. + private readonly HashSet _workingIndicators = new(); + private readonly HashSet _pulsingIndicators = new(); + private bool _workingIndicatorStateHooked; + private static DoubleAnimation CreateWorkingPulse() { var anim = new DoubleAnimation @@ -41,29 +48,61 @@ private static DoubleAnimation CreateWorkingPulse() private static SolidColorBrush Freeze(SolidColorBrush b) { b.Freeze(); return b; } /// Builds a collapsed indicator; drive it with . - private static TextBlock BuildWorkingIndicator(Thickness margin) => new() + private TextBlock BuildWorkingIndicator(Thickness margin) { - Foreground = s_workingBrush, - FontSize = 10, - Margin = margin, - VerticalAlignment = VerticalAlignment.Center, - TextTrimming = TextTrimming.CharacterEllipsis, - Visibility = Visibility.Collapsed - }; + if (!_workingIndicatorStateHooked) + { + _workingIndicatorStateHooked = true; + StateChanged += (_, _) => + { + foreach (var tb in _workingIndicators) RefreshWorkingPulse(tb); + }; + } - /// - /// Shows/hides and relabels a working indicator from the VM. The pulse runs only while - /// visible — a collapsed element with a running clock still ticks. - /// - private static void UpdateWorkingIndicator(TextBlock tb, SessionViewModel vm, bool compact) + var tb = new TextBlock + { + Foreground = s_workingBrush, + FontSize = 10, + Margin = margin, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + Visibility = Visibility.Collapsed + }; + // IsVisible covers what Visibility alone does not: a sidebar row filtered out of the + // tree, a pane not in the current layout, a collapsed ancestor. Each would otherwise + // keep a clock ticking for something nobody can see. + tb.IsVisibleChanged += (_, _) => RefreshWorkingPulse(tb); + tb.Loaded += (_, _) => { _workingIndicators.Add(tb); RefreshWorkingPulse(tb); }; + tb.Unloaded += (_, _) => { _workingIndicators.Remove(tb); RefreshWorkingPulse(tb); }; + return tb; + } + + /// Pulse only while the indicator can actually be seen. + private void RefreshWorkingPulse(TextBlock tb) + { + bool shouldPulse = tb.Visibility == Visibility.Visible + && tb.IsVisible + && WindowState != WindowState.Minimized; + if (shouldPulse == _pulsingIndicators.Contains(tb)) return; + if (shouldPulse) + { + _pulsingIndicators.Add(tb); + tb.BeginAnimation(UIElement.OpacityProperty, s_workingPulse); + } + else + { + _pulsingIndicators.Remove(tb); + tb.BeginAnimation(UIElement.OpacityProperty, null); + } + } + + /// Shows/hides and relabels a working indicator from the VM. + private void UpdateWorkingIndicator(TextBlock tb, SessionViewModel vm, bool compact) { if (!vm.IsWorking) { - if (tb.Visibility != Visibility.Collapsed) - { - tb.BeginAnimation(UIElement.OpacityProperty, null); - tb.Visibility = Visibility.Collapsed; - } + if (tb.Visibility != Visibility.Collapsed) tb.Visibility = Visibility.Collapsed; + RefreshWorkingPulse(tb); return; } @@ -76,11 +115,7 @@ private static void UpdateWorkingIndicator(TextBlock tb, SessionViewModel vm, bo tb.ToolTip = string.IsNullOrEmpty(vm.ProgramStatusMessage) ? "Working (reported by the program)" : vm.ProgramStatusMessage; - - if (tb.Visibility != Visibility.Visible) - { - tb.Visibility = Visibility.Visible; - tb.BeginAnimation(UIElement.OpacityProperty, s_workingPulse); - } + if (tb.Visibility != Visibility.Visible) tb.Visibility = Visibility.Visible; + RefreshWorkingPulse(tb); } } diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 14d57bc..d2badc9 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -1648,7 +1648,7 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal { _sessionManager.UpdateStatus(session.Id, SessionStatus.Exited); // OSC 7501 lifetime: working/blocked records die with the process. - _vm.DropTransientProgramStatus(vm); + _vm.OnProgramProcessExited(vm); RefreshSidebarItem(session.Id); }); }; @@ -3474,6 +3474,20 @@ private System.Windows.Controls.ContextMenu BuildSessionContextMenu(SessionViewM menu.Items.Add(resetColor); } + // OSC 7501: the way out of a status that got stuck (a program that died mid-turn + // without clearing). Drops every record and hands the session back to the + // heuristic until the program reports again. Runtime-only, so no save. + if (vm.HasProgramStatus) + { + var clearStatus = new System.Windows.Controls.MenuItem + { + Header = "Clear status", + ToolTip = "Forget the state this program reported (working / waiting / done)" + }; + clearStatus.Click += (_, _) => _vm.ClearProgramStatus(vm); + menu.Items.Add(clearStatus); + } + // Folder actions — only when there's a local working folder to open. if (!vm.Session.IsRemote && !string.IsNullOrEmpty(vm.Session.WorkingFolder)) { diff --git a/src/CodeShellManager/Models/AppState.cs b/src/CodeShellManager/Models/AppState.cs index f1a1dc5..2a101db 100644 --- a/src/CodeShellManager/Models/AppState.cs +++ b/src/CodeShellManager/Models/AppState.cs @@ -107,6 +107,16 @@ public class AppSettings /// public bool DebugTerminalTrace { get; set; } = false; + /// + /// Experimental, off by default, no UI: answer a program's OSC 7501 feature query + /// (OSC 7501 ; ? ST). Under the inbox ConPTY the reply cannot win the race against + /// conhost's own DA1 answer, so a prober like Claude Code receives it late — as unsolicited + /// input beginning with ESC. Even when on, replies are gated hard + /// (). Incoming 7501 REPORTS are always parsed. + /// Enable by setting it to true in state.json. See CLAUDE.md "Program status (OSC 7501)". + /// + public bool AnswerProgramStatusProbe { get; set; } = false; + // Terminal font settings public string TerminalFontFamily { get; set; } = "'Cascadia Code', 'Cascadia Mono', Consolas, 'Courier New', monospace"; public int TerminalFontSize { get; set; } = 14; diff --git a/src/CodeShellManager/Services/AlertDetector.cs b/src/CodeShellManager/Services/AlertDetector.cs index acb37b6..5680b36 100644 --- a/src/CodeShellManager/Services/AlertDetector.cs +++ b/src/CodeShellManager/Services/AlertDetector.cs @@ -27,7 +27,7 @@ public AlertDetector(string sessionId, string sessionName) _sessionName = sessionName; } - /// True once ran; sticky for this detector's lifetime. + /// True between and . public bool IsStoodDown => _stoodDown; /// @@ -44,6 +44,18 @@ public void StandDown() _idleTimer = null; } + /// + /// Undo : the program no longer holds any OSC 7501 record (it + /// cleared them, it exited, or the user chose "Clear status"), so the heuristic is the + /// only source again. Starts from a clean slate — no stale last line, no fired flag. + /// + public void Rearm() + { + _lastLine = ""; + _alertFired = false; + _stoodDown = false; + } + public void Feed(string rawOutput) { if (_stoodDown) return; diff --git a/src/CodeShellManager/Services/ProgramStatusPayload.cs b/src/CodeShellManager/Services/ProgramStatusPayload.cs index e71622b..8ae1915 100644 --- a/src/CodeShellManager/Services/ProgramStatusPayload.cs +++ b/src/CodeShellManager/Services/ProgramStatusPayload.cs @@ -72,9 +72,10 @@ public static class ProgramStatusPayload /// here — nothing from the query is echoed. Omitting states=/kinds= means /// "the baseline sets", which is exactly what we accept. /// - /// Required, not optional: Claude Code sends the query at startup and, if it gets no reply - /// before its device-attributes sentinel, logs probe: no reply to OSC 7501 ; ? and - /// never reports status at all. + /// Claude Code sends the query at startup and, if it gets no reply before its + /// device-attributes sentinel, logs probe: no reply to OSC 7501 ; ? and never reports. + /// Under the inbox ConPTY that sentinel is answered by conhost first, so the reply is OFF by + /// default and gated (). /// /// public const string ProbeReply = "\x1b]7501;?\x1b\\"; @@ -106,8 +107,9 @@ internal static string EncodeAsWin32InputMode(string text) } /// True when the OSC payload is the feature query (?) rather than a report. + /// Only an exact ? (after trimming) counts — the query carries no pairs. public static bool IsQuery(string? payload) => - payload != null && payload.Trim().StartsWith('?'); + payload != null && payload.Trim() == "?"; /// /// Parses and validates one report payload (the text after 7501;). Returns null when @@ -127,7 +129,9 @@ public static bool IsQuery(string? payload) => if (eq <= 0) continue; // malformed → skip string key = pair[..eq].Trim(); string value = pair[(eq + 1)..].Trim(); - if (!IsValidKey(key) || !IsValidValue(value)) continue; // malformed → skip + if (!IsKeyShaped(key)) continue; // malformed → skip + if (key.Length > MaxKeyBytes) return null; // a LIMIT → drop whole report + if (!IsValidValue(value)) continue; // malformed → skip fields[key] = value; // repeated → last wins } @@ -154,11 +158,10 @@ public static bool IsQuery(string? payload) => } string? app = null; - if (fields.TryGetValue("app", out var appText)) - { - if (appText.Length > MaxAppBytes) return null; // ASCII alphabet: chars == bytes - if (appText.Length > 0) app = appText; - } + // app: 1–32 of [A-Za-z0-9_.+-]; anything else is treated as absent (it is only a + // grouping label, so an odd one is not worth dropping the state it came with). + if (fields.TryGetValue("app", out var appText) && IsIdentifierSegment(appText, MaxAppBytes)) + app = appText; string? title = null; if (fields.TryGetValue("title", out var titleB64)) @@ -203,14 +206,28 @@ public static bool IsQuery(string? payload) => string.IsNullOrEmpty(msg) ? null : msg); } - internal static bool IsValidKey(string key) + /// [a-z]+, any length — the 16-byte cap is a limit, checked separately. + internal static bool IsKeyShaped(string key) { - if (key.Length == 0 || key.Length > MaxKeyBytes) return false; + if (key.Length == 0) return false; foreach (char c in key) if (c is < 'a' or > 'z') return false; return true; } + /// [A-Za-z0-9_.+-]{1,max} — one id segment, or an app value. + internal static bool IsIdentifierSegment(string s, int maxLength) + { + if (s.Length == 0 || s.Length > maxLength) return false; + foreach (char c in s) + { + bool ok = c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' + or '_' or '.' or '+' or '-'; + if (!ok) return false; + } + return true; + } + internal static bool IsValidValue(string value) { foreach (char c in value) @@ -222,14 +239,14 @@ internal static bool IsValidValue(string value) return true; } - /// Slash path: 1–8 non-empty segments of ≤32 bytes, ≤128 bytes in all. + /// Slash path: 1–8 segments of [A-Za-z0-9_.+-]{1,32}, ≤128 bytes in all. internal static bool IsValidId(string id) { if (id.Length == 0 || id.Length > MaxIdBytes) return false; var segments = id.Split('/'); if (segments.Length > MaxIdDepth) return false; foreach (var s in segments) - if (s.Length == 0 || s.Length > MaxIdSegmentBytes) return false; + if (!IsIdentifierSegment(s, MaxIdSegmentBytes)) return false; return true; } @@ -267,12 +284,17 @@ internal static bool TryDecodeText(string b64, int maxDecodedBytes, out string? return false; } + // By rune, not by char: format characters outside the BMP (the tag block, + // U+E0001 and U+E0020–U+E007F) are surrogate pairs and invisible all the same. var sb = new StringBuilder(decoded.Length); - foreach (char c in decoded) + foreach (Rune r in decoded.EnumerateRunes()) { - if (char.IsControl(c)) return false; // C0, DEL, C1 — incl. CR/LF - if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.Format) continue; - sb.Append(c); + if (Rune.IsControl(r)) return false; // C0, DEL, C1 — incl. CR/LF/NEL + var cat = Rune.GetUnicodeCategory(r); + // U+2028 / U+2029: "single line" means no line break of any kind. + if (cat is UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator) return false; + if (cat == UnicodeCategory.Format) continue; + sb.Append(r.ToString()); } text = sb.ToString(); return true; diff --git a/src/CodeShellManager/Services/ProgramStatusProbeGate.cs b/src/CodeShellManager/Services/ProgramStatusProbeGate.cs new file mode 100644 index 0000000..5be9939 --- /dev/null +++ b/src/CodeShellManager/Services/ProgramStatusProbeGate.cs @@ -0,0 +1,62 @@ +using System; + +namespace CodeShellManager.Services; + +/// +/// Decides whether one session may answer an OSC 7501 feature query. WPF-free and +/// thread-safe: runs on the PTY read thread, +/// on the UI thread. Tested in ProgramStatusProbeGateTests. +/// +/// Every reply is written to the program's stdin, and under the inbox ConPTY it always +/// arrives after conhost's own DA1 answer — i.e. as late, unsolicited input starting with +/// ESC, which a TUI may read as an Escape keypress. So it is gated four ways: the setting +/// must be on; this session's ConPTY must have asked for win32-input-mode +/// (ESC [ ? 9001 h, the only encoding conhost does not drop); at most one reply per +/// ; and at most ever. +/// +/// +public sealed class ProgramStatusProbeGate +{ + public static readonly TimeSpan MinInterval = TimeSpan.FromSeconds(5); + public const int MaxRepliesPerSession = 3; + + private const string Win32InputOn = "\x1b[?9001h"; + private const string Win32InputOff = "\x1b[?9001l"; + + private readonly object _lock = new(); + private bool _win32InputMode; + private int _replies; + private DateTime _lastReplyUtc = DateTime.MinValue; + + public bool Win32InputMode { get { lock (_lock) return _win32InputMode; } } + + /// + /// Tracks win32-input-mode from PTY output; the last of ?9001h/?9001l in a + /// chunk wins. Cheap on the hot path: one ordinal IndexOf for the common prefix. A + /// sequence split across two chunks is missed — conhost emits it as the very first bytes + /// of a session, in one write. + /// + public void ObserveOutput(string chunk) + { + if (chunk.IndexOf("\x1b[?9001", StringComparison.Ordinal) < 0) return; + int on = chunk.LastIndexOf(Win32InputOn, StringComparison.Ordinal); + int off = chunk.LastIndexOf(Win32InputOff, StringComparison.Ordinal); + if (on < 0 && off < 0) return; + lock (_lock) _win32InputMode = on > off; + } + + /// True (and consumes a reply) when a reply may be sent now. + public bool TryAcquire(bool enabled, DateTime utcNow) + { + if (!enabled) return false; + lock (_lock) + { + if (!_win32InputMode) return false; + if (_replies >= MaxRepliesPerSession) return false; + if (utcNow - _lastReplyUtc < MinInterval) return false; + _replies++; + _lastReplyUtc = utcNow; + return true; + } + } +} diff --git a/src/CodeShellManager/Services/ProgramStatusRecords.cs b/src/CodeShellManager/Services/ProgramStatusRecords.cs index 3f8ec06..0b43bff 100644 --- a/src/CodeShellManager/Services/ProgramStatusRecords.cs +++ b/src/CodeShellManager/Services/ProgramStatusRecords.cs @@ -46,8 +46,18 @@ private sealed record Entry(ProgramStatusReport Report, long Seq); public int Count => _records.Count; /// Applies one validated report. Returns true when the stored records changed. - public bool Apply(ProgramStatusReport report) + public bool Apply(ProgramStatusReport report) => Apply(report, out _); + + /// + /// Applies one validated report. is true when THIS + /// record just entered blocked/done/error — it did not exist, or held a different state or + /// kind. Judged per record, not from the aggregate: a long-lived working child outranks + /// the root, so the root's turn-end done would never change the aggregate and the + /// user would get no badge. + /// + public bool Apply(ProgramStatusReport report, out bool enteredAttention) { + enteredAttention = false; if (report.State == ProgramState.Clear) { if (report.Id is null) @@ -60,6 +70,12 @@ public bool Apply(ProgramStatusReport report) } string key = report.Id ?? RootKey; + if (IsAttention(report.State)) + { + enteredAttention = !_records.TryGetValue(key, out var prev) + || prev.Report.State != report.State + || prev.Report.Kind != report.Kind; + } // Each report fully REPLACES its record — nothing carries over from the previous one. _records[key] = new Entry(report, ++_seq); @@ -88,7 +104,34 @@ public bool DropTransient() => /// change. Returns true when anything was removed. /// public bool Acknowledge() => - RemoveWhere(r => r.State is ProgramState.Done or ProgramState.Error); + HasUnacknowledgedResults && RemoveWhere(r => r.State is ProgramState.Done or ProgramState.Error); + + /// + /// Any done/error record held. Allocation-free — it is checked on every keystroke. + /// + public bool HasUnacknowledgedResults + { + get + { + foreach (var e in _records.Values) + if (e.Report.State is ProgramState.Done or ProgramState.Error) return true; + return false; + } + } + + /// Any record in blocked/done/error — i.e. a reason for the attention badge to stand. + public bool HasAttentionRecord + { + get + { + foreach (var e in _records.Values) + if (IsAttention(e.Report.State)) return true; + return false; + } + } + + private static bool IsAttention(ProgramState s) => + s is ProgramState.Blocked or ProgramState.Done or ProgramState.Error; /// app for a record, inherited from the nearest ancestor that has one. public string? EffectiveApp(string? id) diff --git a/src/CodeShellManager/Terminal/TerminalBridge.cs b/src/CodeShellManager/Terminal/TerminalBridge.cs index ab7913b..6a91324 100644 --- a/src/CodeShellManager/Terminal/TerminalBridge.cs +++ b/src/CodeShellManager/Terminal/TerminalBridge.cs @@ -129,6 +129,10 @@ public sealed class TerminalBridge : IDisposable /// OSC 133 A: a new shell prompt started (OSC 7501 drops live records on it). public event Action? PromptStarted; + // Gates answers to the OSC 7501 feature query: off unless AnswerProgramStatusProbe, and + // then only in win32-input-mode, rate-limited and capped. See ProgramStatusProbeGate. + private readonly Services.ProgramStatusProbeGate _probeGate = new(); + /// /// Fires when the user presses a keyboard accelerator (Ctrl-combo, F-key, etc.) /// while the WebView2 has focus. Subscribers set e.Handled = true to prevent @@ -467,6 +471,7 @@ private void OnPtyData(string rawData) Trace($"OUTPUT recv len={rawData.Length} gap-since-prev={gap}ms"); } + _probeGate.ObserveOutput(rawData); RawOutputReceived?.Invoke(rawData); PostBootDoneIfNeeded(); @@ -690,10 +695,30 @@ private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceived // Deliberately does NOT raise UserInput: a terminal reply is not the user. // Win32-input-mode encoded, because conhost drops OSC sequences written to // the ConPTY input as plain VT — see ProgramStatusPayload.ProbeReplyForConPty. + // + // OFF by default (AppSettings.AnswerProgramStatusProbe): under the inbox + // ConPTY the reply always loses to conhost's own DA1 answer, so it only ever + // reaches the program as late, unsolicited input starting with ESC. When on, + // ProgramStatusProbeGate still requires win32-input-mode, a 5s interval and a + // lifetime cap. The write goes to the thread pool, never the UI thread. case "programStatusProbe": + { + bool enabled = DebugSettings?.AnswerProgramStatusProbe == true; + if (!_probeGate.TryAcquire(enabled, DateTime.UtcNow)) + { + Trace($"OSC7501 probe not answered (enabled={enabled} w32={_probeGate.Win32InputMode})"); + break; + } Trace("OSC7501 probe answered"); - _pty?.Write(Services.ProgramStatusPayload.ProbeReplyForConPty); + var pty = _pty; + if (pty != null) + _ = System.Threading.Tasks.Task.Run(() => + { + try { pty.Write(Services.ProgramStatusPayload.ProbeReplyForConPty); } + catch { /* PTY torn down meanwhile */ } + }); break; + } case "programStatus": { diff --git a/src/CodeShellManager/ViewModels/MainViewModel.cs b/src/CodeShellManager/ViewModels/MainViewModel.cs index a9f407a..9aae0a9 100644 --- a/src/CodeShellManager/ViewModels/MainViewModel.cs +++ b/src/CodeShellManager/ViewModels/MainViewModel.cs @@ -333,7 +333,22 @@ void Promote() // Clicking into a pane must promote it too. This can only come from the page — // see TerminalBridge.PaneActivated for why WPF never sees the click. vm.Bridge.PaneActivated += Promote; - vm.Bridge.UserInput += () => vm.AlertDetector?.NotifyUserInteracted(); + // UserInput is every onData, which includes xterm's own automatic replies + // (device attributes, cursor reports, focus in/out). For the heuristic that has + // always been the "interaction" signal; for an OSC 7501 session it must not be, + // or a terminal auto-reply would acknowledge a done the user never saw. + vm.Bridge.UserInput += () => + { + if (!vm.HasProgramStatus) vm.AlertDetector?.NotifyUserInteracted(); + }; + // A real key event (onKey) acknowledges a 7501 done/error. The guard is + // allocation-free and false on almost every keystroke. + vm.Bridge.KeyboardInput += () => + { + if (!vm.HasUnacknowledgedProgramStatus) return; + vm.ClearAlert(); + RaiseAlertCountIfChanged(); + }; } if (vm.AlertDetector != null) @@ -388,13 +403,27 @@ public void ApplyProgramStatus(SessionViewModel vm, string payload) ToastHelper.Show(vm.DisplayName, notice.Message, Settings.ShowNotificationSound); } - /// The session's process exited or a new prompt began: drop live 7501 records. + /// A new prompt began (OSC 133 A): drop live 7501 records. public void DropTransientProgramStatus(SessionViewModel vm) { vm.DropTransientProgramStatus(); RaiseAlertCountIfChanged(); } + /// The session's PTY exited: drop live 7501 records and ignore late live reports. + public void OnProgramProcessExited(SessionViewModel vm) + { + vm.OnProcessExited(); + RaiseAlertCountIfChanged(); + } + + /// Sidebar "Clear status": drop every 7501 record and re-arm the heuristic. + public void ClearProgramStatus(SessionViewModel vm) + { + vm.ClearProgramStatus(); + RaiseAlertCountIfChanged(); + } + private void OnSessionCloseRequested(SessionViewModel vm) { vm.CloseRequested -= OnSessionCloseRequested; diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs index ee9d74d..f56322d 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -436,41 +436,102 @@ public void ClearColorOverride() var report = ProgramStatusPayload.TryParse(payload); if (report is null) return null; + // Exit race: web messages queued behind the PTY's exit describe a process that is + // gone. A live state from it would never be retired (no heartbeat, no further exit), + // leaving a stuck working/blocked indicator. Results and clears are still honoured. + if (_processExited && report.State is ProgramState.Working or ProgramState.Blocked or ProgramState.Idle) + return null; + bool first = !HasProgramStatus; if (first) { HasProgramStatus = true; AlertDetector?.StandDown(); } - _programStatus.Apply(report); - bool entered = SyncProgramStatus(force: first); + _programStatus.Apply(report, out bool entered); + string? enteredMessage = entered + ? DescribeProgramStatus(report.State, report.Kind, report.Message) + : null; + SyncProgramStatus(force: first, enteredMessage); + + // A clear that emptied the store hands the session back to the heuristic. + if (report.State == ProgramState.Clear && _programStatus.Count == 0) RearmHeuristic(); if (!entered) return null; if (utcNow - _lastProgramToastUtc < ProgramStatusToastInterval) return null; _lastProgramToastUtc = utcNow; - return new ProgramStatusNotice(AlertMessage); + return new ProgramStatusNotice(enteredMessage!); } /// - /// The session's process exited, or a new shell prompt began (OSC 133 A). Drops the live - /// records per the spec's lifetime rule; done/error survive until acknowledged. + /// A new shell prompt began (OSC 133 A), or the process exited. Drops the live records + /// per the spec's lifetime rule; done/error survive until acknowledged. If nothing is + /// left, the heuristic is re-armed. /// public void DropTransientProgramStatus() { if (!HasProgramStatus) return; - if (_programStatus.DropTransient()) SyncProgramStatus(force: false); + if (_programStatus.DropTransient()) SyncProgramStatus(force: false, enteredMessage: null); + if (_programStatus.Count == 0) RearmHeuristic(); + } + + /// + /// The session's PTY exited. From here on, live-state reports still in flight are ignored + /// (see ). + /// + public void OnProcessExited() + { + _processExited = true; + DropTransientProgramStatus(); + } + + /// + /// "Clear status" from the sidebar menu: the user's way out of a state that got stuck + /// (a program that died mid-turn without a clear, say). Drops every record and hands the + /// session back to the heuristic until the program reports again. + /// + public void ClearProgramStatus() + { + if (!HasProgramStatus) return; + _programStatus.Apply(new ProgramStatusReport(ProgramState.Clear)); + SyncProgramStatus(force: false, enteredMessage: null); + RearmHeuristic(); + } + + /// + /// True when a keystroke has something to acknowledge (a badge or a done/error record). + /// Allocation-free — MainViewModel checks it on every key event. + /// + public bool HasUnacknowledgedProgramStatus => + HasProgramStatus && (NeedsAttention || _programStatus.HasUnacknowledgedResults); + + private bool _processExited; + + /// + /// Undo the stand-down: the program no longer holds any record, so the screen heuristic + /// is the only source again. Not done on acknowledge — a Claude session whose done + /// was just seen would otherwise have the regex guess at its prompt again. + /// + private void RearmHeuristic() + { + if (!HasProgramStatus) return; + HasProgramStatus = false; + AlertDetector?.Rearm(); } /// - /// Re-derives the session's indicators from the records. Returns true when the session - /// just ENTERED an attention state — the only moment worth a badge and a toast. A report - /// that merely restates the current state (a new progress value, a new msg) is not one. + /// Re-derives the session's indicators from the records. The dots and the working + /// indicator follow the aggregate. The badge is raised from + /// — a RECORD that just entered blocked/done/error, so a root done still badges + /// while a child keeps the aggregate at working — and cleared only once no record is in + /// an attention state, so that child work does not wipe an unseen result. /// - private bool SyncProgramStatus(bool force) + private void SyncProgramStatus(bool force, string? enteredMessage) { var prev = _programSummary; var next = _programStatus.Summarize(); - if (!force && next == prev) return false; + bool summaryChanged = force || next != prev; + if (!summaryChanged && enteredMessage is null) return; _programSummary = next; bool blocked = next.State == ProgramState.Blocked; @@ -478,29 +539,30 @@ private bool SyncProgramStatus(bool force) // question, auth, and a blocked report with no kind all mean "your turn to type". IsWaitingForInput = blocked && next.Kind != BlockedKind.Permission; - bool entered = next.WantsAttention - && (force || prev.State != next.State || prev.Kind != next.Kind); - if (entered) + if (enteredMessage is not null) { NeedsAttention = true; - AlertMessage = DescribeProgramStatus(next); + AlertMessage = enteredMessage; } - else if (!next.WantsAttention) + else if (!_programStatus.HasAttentionRecord) { - // Working or idle again: the moment that needed the user has passed. + // Nothing needs the user any more. NeedsAttention = false; AlertMessage = ""; } + if (!summaryChanged) return; _programStatusVersion++; OnPropertyChanged(nameof(ProgramStatusVersion)); - return entered; } /// Short one-liner for the badge tooltip / toast. msg is already sanitised. - internal static string DescribeProgramStatus(ProgramStatusSummary s) + internal static string DescribeProgramStatus(ProgramStatusSummary s) => + DescribeProgramStatus(s.State, s.Kind, s.Message); + + internal static string DescribeProgramStatus(ProgramState? state, BlockedKind kind, string? message) { - string text = s.Message ?? (s.State, s.Kind) switch + string text = message ?? (state, kind) switch { (ProgramState.Blocked, BlockedKind.Permission) => "Needs permission", (ProgramState.Blocked, BlockedKind.Auth) => "Sign-in required", @@ -587,7 +649,7 @@ public void ClearAlert() // here would blank an orange "needs permission" dot on the first keystroke. NeedsAttention = false; AlertMessage = ""; - if (_programStatus.Acknowledge()) SyncProgramStatus(force: false); + if (_programStatus.Acknowledge()) SyncProgramStatus(force: false, enteredMessage: null); return; } diff --git a/tests/CodeShellManager.Tests/ProgramStatusPayloadTests.cs b/tests/CodeShellManager.Tests/ProgramStatusPayloadTests.cs index 473fb90..1f6f36b 100644 --- a/tests/CodeShellManager.Tests/ProgramStatusPayloadTests.cs +++ b/tests/CodeShellManager.Tests/ProgramStatusPayloadTests.cs @@ -20,11 +20,13 @@ public class ProgramStatusPayloadTests [Theory] [InlineData("?")] [InlineData(" ? ")] - [InlineData("?:states=idle")] public void IsQuery_recognises_the_feature_query(string payload) => Assert.True(ProgramStatusPayload.IsQuery(payload)); [Theory] + [InlineData("?:states=idle")] // only an exact "?" is a query + [InlineData("??")] + [InlineData("?x")] [InlineData("state=idle")] [InlineData("")] [InlineData(null)] @@ -106,7 +108,6 @@ public void Repeated_key_last_wins() => [InlineData("state=working:=x")] // empty key [InlineData("state=working:Bad=1")] // uppercase key [InlineData("state=working:k3y=1")] // digit in key - [InlineData("state=working:abcdefghijklmnopq=1")] // 17-byte key [InlineData("state=working:app=a b")] // space inside a value [InlineData("state=working:app=a;b")] // ';' not in alphabet public void Malformed_pairs_are_skipped_not_fatal(string payload) @@ -117,6 +118,13 @@ public void Malformed_pairs_are_skipped_not_fatal(string payload) Assert.Null(r.App); } + [Fact] + public void Key_over_16_bytes_is_a_limit_and_drops_the_whole_report() + { + Assert.NotNull(ProgramStatusPayload.TryParse("state=working:abcdefghijklmnop=1")); // 16 + Assert.Null(ProgramStatusPayload.TryParse("state=working:abcdefghijklmnopq=1")); // 17 + } + [Fact] public void Malformed_state_pair_means_no_state() => Assert.Null(ProgramStatusPayload.TryParse("state=work ing")); @@ -187,6 +195,9 @@ public void Valid_ids_parse(string id) => [InlineData("build/")] [InlineData("a//b")] [InlineData("")] + [InlineData("a,b")] // ',' is a value char, not an id char + [InlineData("a=b")] + [InlineData("build/x=1")] public void Invalid_id_ignores_the_report_rather_than_writing_root(string id) => Assert.Null(ProgramStatusPayload.TryParse($"state=working:id={id}")); @@ -209,11 +220,26 @@ public void Id_over_128_bytes_ignores_the_report() } [Fact] - public void App_over_32_bytes_drops_the_report() + public void App_allows_32_identifier_bytes() { Assert.Equal(new string('x', 32), ProgramStatusPayload.TryParse($"state=idle:app={new string('x', 32)}")!.App); - Assert.Null(ProgramStatusPayload.TryParse($"state=idle:app={new string('x', 33)}")); + Assert.Equal("my_app-1.2+x", + ProgramStatusPayload.TryParse("state=idle:app=my_app-1.2+x")!.App); + } + + [Theory] + [InlineData("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")] // 33 + [InlineData("a/b")] + [InlineData("a,b")] + [InlineData("a=b")] + [InlineData("")] + public void Invalid_app_is_treated_as_absent_not_fatal(string app) + { + var r = ProgramStatusPayload.TryParse($"state=working:app={app}"); + Assert.NotNull(r); + Assert.Null(r!.App); + Assert.Equal(ProgramState.Working, r.State); } // ---- base64 text ---------------------------------------------------------------------- @@ -270,6 +296,34 @@ public void Bidi_and_invisible_format_characters_are_neutralised() Assert.Equal("oktxt.exe", r!.Message); } + [Fact] + public void Supplementary_plane_format_characters_are_stripped_by_rune() + { + // U+E0001 LANGUAGE TAG and U+E0041 TAG LATIN CAPITAL A — surrogate pairs in UTF-16, + // invisible, and category Cf. A per-char check sees two surrogates and keeps them. + string tagged = "ok" + char.ConvertFromUtf32(0xE0001) + char.ConvertFromUtf32(0xE0041) + "!"; + Assert.Equal("ok!", ProgramStatusPayload.TryParse($"state=done:msg={B64(tagged)}")!.Message); + } + + [Fact] + public void Visible_supplementary_characters_survive() + { + string emoji = "done " + char.ConvertFromUtf32(0x1F389); + Assert.Equal(emoji, ProgramStatusPayload.TryParse($"state=done:msg={B64(emoji)}")!.Message); + } + + [Theory] + [InlineData(0x2028)] // LINE SEPARATOR + [InlineData(0x2029)] // PARAGRAPH SEPARATOR + [InlineData(0x0085)] // NEL (C1) + public void Unicode_line_breaks_drop_the_report(int codePoint) + { + // Code points, not literals: a raw U+2028 inside a C# string literal is a line break. + string text = "a" + char.ConvertFromUtf32(codePoint) + "b"; + Assert.Null(ProgramStatusPayload.TryParse($"state=done:msg={B64(text)}")); + Assert.Null(ProgramStatusPayload.TryParse($"state=done:title={B64(text)}")); + } + [Fact] public void Title_decoded_limit_is_192_bytes() { diff --git a/tests/CodeShellManager.Tests/ProgramStatusProbeGateTests.cs b/tests/CodeShellManager.Tests/ProgramStatusProbeGateTests.cs new file mode 100644 index 0000000..1095e5f --- /dev/null +++ b/tests/CodeShellManager.Tests/ProgramStatusProbeGateTests.cs @@ -0,0 +1,73 @@ +using System; +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// : an OSC 7501 probe reply is written to the program's +/// stdin, and under the inbox ConPTY it always arrives late (after conhost's own DA1 answer), +/// so it is off by default and, when enabled, gated by win32-input-mode, rate and a cap. +/// +public class ProgramStatusProbeGateTests +{ + private static readonly DateTime T0 = new(2026, 11, 6, 12, 0, 0, DateTimeKind.Utc); + + private static ProgramStatusProbeGate Win32Gate() + { + var g = new ProgramStatusProbeGate(); + g.ObserveOutput("\x1b[?9001h\x1b[?1004h\x1b[?25l"); // conhost's opening bytes + return g; + } + + [Fact] + public void Disabled_setting_never_replies() + { + var g = Win32Gate(); + Assert.False(g.TryAcquire(enabled: false, T0)); + } + + [Fact] + public void No_reply_until_the_ConPTY_asked_for_win32_input_mode() + { + var g = new ProgramStatusProbeGate(); + Assert.False(g.TryAcquire(enabled: true, T0)); + g.ObserveOutput("plain output, no mode change"); + Assert.False(g.TryAcquire(enabled: true, T0)); + g.ObserveOutput("\x1b[?9001h"); + Assert.True(g.TryAcquire(enabled: true, T0)); + } + + [Fact] + public void Win32_input_mode_off_stops_replies_and_last_toggle_in_a_chunk_wins() + { + var g = Win32Gate(); + g.ObserveOutput("\x1b[?9001l"); + Assert.False(g.Win32InputMode); + g.ObserveOutput("\x1b[?9001l...\x1b[?9001h"); + Assert.True(g.Win32InputMode); + g.ObserveOutput("\x1b[?9001h...\x1b[?9001l"); + Assert.False(g.Win32InputMode); + Assert.False(g.TryAcquire(enabled: true, T0)); + } + + [Fact] + public void At_most_one_reply_per_interval() + { + var g = Win32Gate(); + Assert.True(g.TryAcquire(true, T0)); + Assert.False(g.TryAcquire(true, T0.AddSeconds(1))); + Assert.False(g.TryAcquire(true, T0 + ProgramStatusProbeGate.MinInterval - TimeSpan.FromMilliseconds(1))); + Assert.True(g.TryAcquire(true, T0 + ProgramStatusProbeGate.MinInterval)); + } + + [Fact] + public void Lifetime_cap_per_session() + { + var g = Win32Gate(); + int granted = 0; + for (int i = 0; i < 20; i++) + if (g.TryAcquire(true, T0.AddMinutes(i))) granted++; + Assert.Equal(ProgramStatusProbeGate.MaxRepliesPerSession, granted); + } +} diff --git a/tests/CodeShellManager.Tests/ProgramStatusRecordsTests.cs b/tests/CodeShellManager.Tests/ProgramStatusRecordsTests.cs index 999fc52..2631f33 100644 --- a/tests/CodeShellManager.Tests/ProgramStatusRecordsTests.cs +++ b/tests/CodeShellManager.Tests/ProgramStatusRecordsTests.cs @@ -174,6 +174,49 @@ public void Process_exit_drops_live_records_and_keeps_results() Assert.False(store.DropTransient()); // idempotent } + [Fact] + public void EnteredAttention_is_judged_per_record() + { + var store = new ProgramStatusRecords(); + store.Apply(R(ProgramState.Working, id: "bg"), out bool e1); + Assert.False(e1); + store.Apply(R(ProgramState.Working), out bool e2); + Assert.False(e2); + + // Root finishes while a child keeps working: the aggregate stays "working", but the + // root record entered done — that is the turn end the user must hear about. + store.Apply(R(ProgramState.Done), out bool e3); + Assert.True(e3); + Assert.Equal(ProgramState.Working, store.Summarize().State); + + store.Apply(R(ProgramState.Done, msg: "again"), out bool e4); + Assert.False(e4); // same state restated + + store.Apply(R(ProgramState.Blocked, kind: BlockedKind.Question), out bool e5); + Assert.True(e5); + store.Apply(R(ProgramState.Blocked, kind: BlockedKind.Permission), out bool e6); + Assert.True(e6); // a new kind is a new ask + store.Apply(R(ProgramState.Clear), out bool e7); + Assert.False(e7); + } + + [Fact] + public void Attention_and_result_flags_track_the_records() + { + var store = new ProgramStatusRecords(); + Assert.False(store.HasAttentionRecord); + store.Apply(R(ProgramState.Working)); + Assert.False(store.HasAttentionRecord); + store.Apply(R(ProgramState.Blocked, id: "b")); + Assert.True(store.HasAttentionRecord); + Assert.False(store.HasUnacknowledgedResults); + store.Apply(R(ProgramState.Error, id: "e")); + Assert.True(store.HasUnacknowledgedResults); + store.Acknowledge(); + Assert.False(store.HasUnacknowledgedResults); + Assert.True(store.HasAttentionRecord); // blocked is still live + } + [Fact] public void Acknowledge_retires_done_and_error_only() { diff --git a/tests/CodeShellManager.Tests/SessionViewModelProgramStatusTests.cs b/tests/CodeShellManager.Tests/SessionViewModelProgramStatusTests.cs index 4cf9bfa..df7872a 100644 --- a/tests/CodeShellManager.Tests/SessionViewModelProgramStatusTests.cs +++ b/tests/CodeShellManager.Tests/SessionViewModelProgramStatusTests.cs @@ -205,6 +205,133 @@ public void Process_exit_drops_working_and_blocked_but_keeps_done() Assert.Equal(ProgramState.Done, vm.AgentState); } + [Fact] + public void Root_done_badges_and_toasts_even_while_a_child_keeps_working() + { + var vm = MakeVm(); + vm.ApplyProgramStatus("state=working:id=bg", T0); // long-lived background task + vm.ApplyProgramStatus("state=working", T0); + var notice = vm.ApplyProgramStatus("state=done", T0.AddMinutes(1)); + + Assert.NotNull(notice); + Assert.Equal("Finished", notice!.Message); + Assert.True(vm.NeedsAttention); + Assert.True(vm.IsWorking); // child work stays visible + + // More child progress must not wipe the unseen result. + vm.ApplyProgramStatus("state=working:id=bg:progress=50", T0.AddMinutes(2)); + Assert.True(vm.NeedsAttention); + + vm.ClearAlert(); // seen + Assert.False(vm.NeedsAttention); + Assert.True(vm.IsWorking); + } + + [Fact] + public void Clear_all_rearms_the_heuristic() + { + var vm = MakeVm(); + vm.AlertDetector = new AlertDetector(vm.Id, "s"); + vm.ApplyProgramStatus("state=working", T0); + Assert.True(vm.AlertDetector.IsStoodDown); + + vm.ApplyProgramStatus("state=clear", T0); + Assert.False(vm.HasProgramStatus); + Assert.False(vm.AlertDetector.IsStoodDown); + + vm.RaiseAlert("Allow?", AlertType.ToolApproval); // the heuristic counts again + Assert.True(vm.IsWaitingForApproval); + } + + [Fact] + public void A_child_clear_that_leaves_records_does_not_rearm() + { + var vm = MakeVm(); + vm.AlertDetector = new AlertDetector(vm.Id, "s"); + vm.ApplyProgramStatus("state=idle", T0); + vm.ApplyProgramStatus("state=working:id=bg", T0); + vm.ApplyProgramStatus("state=clear:id=bg", T0); + Assert.True(vm.HasProgramStatus); + Assert.True(vm.AlertDetector.IsStoodDown); + } + + [Fact] + public void Prompt_start_that_empties_the_store_rearms() + { + var vm = MakeVm(); + vm.AlertDetector = new AlertDetector(vm.Id, "s"); + vm.ApplyProgramStatus("state=blocked:kind=permission", T0); + vm.DropTransientProgramStatus(); + Assert.False(vm.HasProgramStatus); + Assert.False(vm.AlertDetector.IsStoodDown); + Assert.False(vm.IsWaitingForApproval); + Assert.False(vm.NeedsAttention); + } + + [Fact] + public void Acknowledging_the_last_result_does_not_rearm() + { + // Otherwise a Claude session whose "done" was just seen would have the regex guess + // at its prompt again. + var vm = MakeVm(); + vm.AlertDetector = new AlertDetector(vm.Id, "s"); + vm.ApplyProgramStatus("state=done", T0); + vm.ClearAlert(); + Assert.True(vm.HasProgramStatus); + Assert.True(vm.AlertDetector.IsStoodDown); + } + + [Fact] + public void Clear_status_menu_drops_everything_and_rearms() + { + var vm = MakeVm(); + vm.AlertDetector = new AlertDetector(vm.Id, "s"); + vm.ApplyProgramStatus("state=working:progress=10", T0); + vm.ApplyProgramStatus("state=done:id=x", T0); + vm.ClearProgramStatus(); + Assert.False(vm.IsWorking); + Assert.False(vm.NeedsAttention); + Assert.Null(vm.AgentState); + Assert.False(vm.HasProgramStatus); + Assert.False(vm.AlertDetector.IsStoodDown); + } + + [Theory] + [InlineData("state=working")] + [InlineData("state=blocked:kind=permission")] + [InlineData("state=idle")] + public void Live_reports_after_exit_are_ignored(string late) + { + var vm = MakeVm(); + vm.ApplyProgramStatus("state=working", T0); + vm.OnProcessExited(); + Assert.Null(vm.ApplyProgramStatus(late, T0)); + Assert.False(vm.IsWorking); + Assert.False(vm.IsWaitingForApproval); + } + + [Fact] + public void Results_after_exit_are_still_shown() + { + var vm = MakeVm(); + vm.OnProcessExited(); + Assert.NotNull(vm.ApplyProgramStatus("state=error", T0)); + Assert.True(vm.NeedsAttention); + } + + [Fact] + public void HasUnacknowledgedProgramStatus_is_false_when_there_is_nothing_to_ack() + { + var vm = MakeVm(); + Assert.False(vm.HasUnacknowledgedProgramStatus); // no 7501 at all + vm.ApplyProgramStatus("state=working", T0); + Assert.False(vm.HasUnacknowledgedProgramStatus); // the per-keystroke common case + vm.ApplyProgramStatus("state=done", T0); + Assert.True(vm.HasUnacknowledgedProgramStatus); + vm.ClearAlert(); + Assert.False(vm.HasUnacknowledgedProgramStatus); + } + [Fact] public void State_changes_raise_one_ProgramStatusVersion_notification() {