From a54a03130afb90a9b74c7c4948e11f4408ef1ecc Mon Sep 17 00:00:00 2001 From: Martin Ottosen Date: Sun, 10 May 2026 22:08:32 +0200 Subject: [PATCH 1/9] feat: add OSC 9001 shell-integration channel for color/git/title --- src/CodeShellManager/Assets/terminal-init.js | 19 ++++++++++ .../Terminal/TerminalBridge.cs | 21 +++++++++++ .../ViewModels/SessionViewModel.cs | 36 +++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/src/CodeShellManager/Assets/terminal-init.js b/src/CodeShellManager/Assets/terminal-init.js index 93ee266..75e3ab7 100644 --- a/src/CodeShellManager/Assets/terminal-init.js +++ b/src/CodeShellManager/Assets/terminal-init.js @@ -32,6 +32,25 @@ term.open(document.getElementById('terminal')); fitAddon.fit(); + // ── Shell integration: OSC 9001;key=value;key=value;ST ───────────────────── + // A program inside the terminal can push session state up to CSM by emitting: + // ESC ] 9001 ; color=#89b4fa ; git-branch=main ; git-dirty=1 ; title=foo ST + // Recognised keys: color, git-branch, git-dirty (0/1), title. + // Returning true tells xterm we consumed the sequence so it isn't rendered. + term.parser.registerOscHandler(9001, data => { + try { + const fields = {}; + for (const part of String(data).split(';')) { + const eq = part.indexOf('='); + if (eq > 0) fields[part.slice(0, eq).trim()] = part.slice(eq + 1).trim(); + } + window.chrome.webview.postMessage(JSON.stringify({ + type: 'shellIntegration', fields + })); + } catch {} + return true; + }); + // ── Input → PTY ──────────────────────────────────────────────────────────── function sendInput(data) { window.chrome.webview.postMessage(JSON.stringify({ type: 'input', data })); diff --git a/src/CodeShellManager/Terminal/TerminalBridge.cs b/src/CodeShellManager/Terminal/TerminalBridge.cs index fa3e001..ce1cf4b 100644 --- a/src/CodeShellManager/Terminal/TerminalBridge.cs +++ b/src/CodeShellManager/Terminal/TerminalBridge.cs @@ -87,6 +87,12 @@ public sealed class TerminalBridge : IDisposable /// public event Action? PaneActivated; + /// + /// Fires when the running shell program emits OSC 9001 (CSM shell integration). + /// Carries the parsed key=value fields it included (color, git-branch, git-dirty, title, …). + /// + public event Action>? ShellIntegrationReceived; + /// /// 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 @@ -400,6 +406,21 @@ private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceived WpfClipboard.SetText(copy)); break; + case "shellIntegration": + if (root.TryGetProperty("fields", out var fieldsEl) + && fieldsEl.ValueKind == JsonValueKind.Object) + { + var dict = new System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var prop in fieldsEl.EnumerateObject()) + { + if (prop.Value.ValueKind == JsonValueKind.String) + dict[prop.Name] = prop.Value.GetString() ?? ""; + } + if (dict.Count > 0) + ShellIntegrationReceived?.Invoke(dict); + } + break; + case "filesDropped": // JS sends full paths via text/uri-list (file:// URIs from Explorer) if (root.TryGetProperty("paths", out var pathsEl)) diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs index ded6a6f..13d3f41 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -127,6 +127,42 @@ private void OpenInExplorer() System.Diagnostics.Process.Start("explorer.exe", Session.WorkingFolder); } + /// + /// Applies a CSM shell-integration payload (OSC 9001) emitted by the running program. + /// Recognised keys: color (#rrggbb), git-branch, git-dirty (0/1), + /// title. Unknown keys are ignored. Useful for SSH overlays whose remote + /// state CSM cannot inspect locally. + /// + public void ApplyShellIntegration(System.Collections.Generic.IReadOnlyDictionary fields) + { + if (fields.TryGetValue("color", out var color) && IsValidHexColor(color)) + { + Session.ColorOverride = color; + OnPropertyChanged(nameof(AccentColor)); + } + + if (fields.TryGetValue("git-branch", out var branch)) + { + GitBranch = string.IsNullOrWhiteSpace(branch) ? null : branch; + GitInfoLoaded = true; + } + + if (fields.TryGetValue("git-dirty", out var dirty)) + GitIsDirty = dirty == "1" || string.Equals(dirty, "true", StringComparison.OrdinalIgnoreCase); + + if (fields.TryGetValue("title", out var title) && !string.IsNullOrWhiteSpace(title)) + Rename(title.Trim()); + } + + private static bool IsValidHexColor(string s) + { + if (string.IsNullOrEmpty(s) || s[0] != '#') return false; + if (s.Length != 4 && s.Length != 7 && s.Length != 9) return false; + for (int i = 1; i < s.Length; i++) + if (!Uri.IsHexDigit(s[i])) return false; + return true; + } + public void RaiseAlert(string message, AlertType alertType = AlertType.InputRequired) { NeedsAttention = true; From 4d45040a888f27f049e487e7cd94c4e8b84e3c44 Mon Sep 17 00:00:00 2001 From: Martin Ottosen Date: Sun, 10 May 2026 22:08:42 +0200 Subject: [PATCH 2/9] docs: document OSC 9001 shell integration --- CLAUDE.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 32bbce9..a285437 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -269,6 +269,29 @@ The snapshot model is `Models/RecentlyClosedEntry.cs` — a separate POCO from ` FTS5 scrollback retention is **out of scope** for v1 — restored sessions start with an empty xterm buffer. +## Shell Integration (OSC 9001) + +Programs running inside a terminal can push session state up to CSM by emitting a custom OSC sequence — useful for SSH overlays (e.g. `nexus`) where CSM cannot inspect the remote repo locally. + +**Wire format:** `ESC ] 9001 ; key=value ; key=value … ST` + +ST may be `BEL` (`\x07`) or `ESC \\` — xterm.js accepts both. + +**Recognised keys:** + +| Key | Effect | +|---|---| +| `color` | Override the session accent (`#rrggbb` / `#rgb` / `#rrggbbaa`). Repaints sidebar stripe + active ring. | +| `git-branch` | Set `SessionViewModel.GitBranch` directly, bypassing `GitService`. | +| `git-dirty` | `1`/`true` → dirty-marker shown; `0`/anything else → clean. | +| `title` | Renames the session (calls `vm.Rename`). | + +Unknown keys are ignored. Multiple keys can be sent in a single sequence. + +**Pipeline:** `terminal-init.js` registers an OSC handler via `term.parser.registerOscHandler(9001, …)` (requires `allowProposedApi: true`, already set). It posts `{type: "shellIntegration", fields: {…}}` to WPF. `TerminalBridge` parses it and raises `ShellIntegrationReceived`. `MainWindow.LaunchSessionAsync` subscribes and calls `vm.ApplyShellIntegration(fields)` on the dispatcher, then `SaveStateAsync` so changes persist. + +The OSC handler returns `true` so xterm consumes the sequence and it doesn't render. + ## 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. From 3265a9a69317e44df14052cffaaf9a279ddfc7c7 Mon Sep 17 00:00:00 2001 From: Martin Ottosen Date: Sun, 10 May 2026 22:55:08 +0200 Subject: [PATCH 3/9] docs: add public shell-integration reference + cross-links --- CLAUDE.md | 2 + README.md | 1 + docs/shell-integration.md | 161 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 docs/shell-integration.md diff --git a/CLAUDE.md b/CLAUDE.md index a285437..7fea1af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -273,6 +273,8 @@ FTS5 scrollback retention is **out of scope** for v1 — restored sessions start Programs running inside a terminal can push session state up to CSM by emitting a custom OSC sequence — useful for SSH overlays (e.g. `nexus`) where CSM cannot inspect the remote repo locally. +> **Integrator-facing reference:** [`docs/shell-integration.md`](docs/shell-integration.md) (wire format + bash/PowerShell/Python/Node/Rust/Go snippets). The notes below are CSM-internal. + **Wire format:** `ESC ] 9001 ; key=value ; key=value … ST` ST may be `BEL` (`\x07`) or `ESC \\` — xterm.js accepts both. diff --git a/README.md b/README.md index 1cef29b..3e9e03a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Built with WPF + [xterm.js](https://xtermjs.org/) + Windows ConPTY for full pseu - **Alert detection** — detects when Claude is waiting for input or tool approval; green/orange dot indicators - **Git status** — shows branch and dirty state in the sidebar per session - **Session rename** — double-click any session name or click ✏ to rename inline +- **Shell integration** — programs running in a session can push their accent color, git branch / dirty state, and tab title to CSM via OSC 9001 (handy for SSH overlays). See [`docs/shell-integration.md`](docs/shell-integration.md). - **Auto-resume** — automatically resumes the last Claude Code session when restoring on startup (`--resume `); toggleable in Settings - **SSH remote sessions** — connect to remote hosts using your existing SSH config; sessions persist across restarts - **Windows Terminal profile import** — opt-in import of profiles from Windows Terminal's `settings.json`; pick a profile in the New Session dialog to stamp its font, color scheme, cursor and padding onto the new terminal diff --git a/docs/shell-integration.md b/docs/shell-integration.md new file mode 100644 index 0000000..20bf3ca --- /dev/null +++ b/docs/shell-integration.md @@ -0,0 +1,161 @@ +# CodeShellManager Shell Integration + +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. + +## Wire format + +``` +ESC ] 9001 ; key=value ; key=value … ST +``` + +- `ESC` is `\x1b` (`0o33`, `27`). +- `9001` is the CSM-namespaced OSC identifier. +- `ST` ("string terminator") is either `BEL` (`\x07`) or `ESC \` (`\x1b\x5c`). Both are accepted. +- Keys and values are separated by `=`. Multiple fields are separated by `;`. +- Whitespace around keys/values is trimmed. +- Unknown keys are silently ignored — safe to emit forward-compatibly. +- The whole sequence is consumed by xterm and never rendered. + +## Recognised keys + +| Key | Value format | Effect | +|--------------|-------------------------|--------| +| `color` | `#rgb`, `#rrggbb`, `#rrggbbaa` | Override the session accent. Repaints the sidebar stripe and the active-pane ring immediately. | +| `git-branch` | string | Set the branch label shown in the sidebar. Bypasses CSM's local `git` polling — useful for SSH/remote sessions. | +| `git-dirty` | `0`/`1` (or `false`/`true`) | Toggle the dirty marker (`*`) shown next to the branch. | +| `title` | string | Rename the session (same as double-clicking the sidebar entry). Persisted to `state.json`. | + +Multiple keys can be sent in a single sequence; CSM applies them atomically and saves state once. + +## Examples + +All examples below emit `color=#a6e3a1`, `git-branch=feat/foo`, `git-dirty=1`, `title=my-repo` in a single sequence. Adapt to your needs. + +### bash / zsh / sh + +```bash +printf '\e]9001;color=#a6e3a1;git-branch=feat/foo;git-dirty=1;title=my-repo\e\\' +``` + +To refresh on every prompt, drop this into your shell init: + +```bash +__csm_update() { + local branch dirty + branch=$(git symbolic-ref --short HEAD 2>/dev/null) || branch="" + [ -n "$(git status --porcelain 2>/dev/null)" ] && dirty=1 || dirty=0 + printf '\e]9001;git-branch=%s;git-dirty=%s\e\\' "$branch" "$dirty" +} +PROMPT_COMMAND='__csm_update' # bash +# precmd_functions+=(__csm_update) # zsh +``` + +### PowerShell + +```powershell +$esc = [char]27 +"$esc]9001;color=#a6e3a1;git-branch=feat/foo;git-dirty=1;title=my-repo$esc\" | Write-Host -NoNewline +``` + +In a `prompt` function: + +```powershell +function prompt { + $esc = [char]27 + $branch = (git symbolic-ref --short HEAD 2>$null) + $dirty = if ((git status --porcelain 2>$null)) { 1 } else { 0 } + Write-Host -NoNewline "$esc]9001;git-branch=$branch;git-dirty=$dirty$esc\" + "PS $($executionContext.SessionState.Path.CurrentLocation)> " +} +``` + +### Python + +```python +import sys + +def csm_update(**fields): + payload = ";".join(f"{k}={v}" for k, v in fields.items()) + sys.stdout.write(f"\x1b]9001;{payload}\x1b\\") + sys.stdout.flush() + +csm_update(color="#a6e3a1", **{"git-branch": "feat/foo", "git-dirty": "1"}, title="my-repo") +``` + +### Node.js + +```js +function csmUpdate(fields) { + const payload = Object.entries(fields).map(([k, v]) => `${k}=${v}`).join(';'); + process.stdout.write(`\x1b]9001;${payload}\x1b\\`); +} + +csmUpdate({ color: '#a6e3a1', 'git-branch': 'feat/foo', 'git-dirty': '1', title: 'my-repo' }); +``` + +### Rust + +```rust +fn csm_update(fields: &[(&str, &str)]) { + let payload: String = fields.iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(";"); + print!("\x1b]9001;{payload}\x1b\\"); + use std::io::Write; + let _ = std::io::stdout().flush(); +} + +csm_update(&[ + ("color", "#a6e3a1"), + ("git-branch", "feat/foo"), + ("git-dirty", "1"), + ("title", "my-repo"), +]); +``` + +### Go + +```go +package main + +import ( + "fmt" + "strings" +) + +func csmUpdate(fields map[string]string) { + parts := make([]string, 0, len(fields)) + for k, v := range fields { + parts = append(parts, k+"="+v) + } + fmt.Printf("\x1b]9001;%s\x1b\\", strings.Join(parts, ";")) +} +``` + +## Patterns + +**Update on every prompt.** Cheap, predictable, and handles `cd` / branch switches automatically. Use the shell snippets above. + +**Update on relevant events only.** If a prompt-hook is too coarse — e.g. inside a long-running TUI like `nexus` — call your update function whenever your internal state changes (new repo selected, dirty state changes, branch checked out, etc.). + +**Reset on exit.** If your program owns the session's accent for its lifetime, restore the default before exiting: + +```bash +# Clearing color sends the empty string, which CSM treats as "use the default hash" +# (only true if you've also chosen to clear ColorOverride; currently CSM keeps the +# last value. To restore the original hash, leave the color key out entirely.) +``` + +In the current build, an emitted `color=` is sticky and persists in `state.json` across restarts. If you want it to revert when your program exits, emit nothing extra — but if a different program later runs in the same session, it will inherit your color until it sets its own. + +## Limitations + +- The protocol is one-way: CSM does not respond to OSC 9001 sequences with any data. +- There's no acknowledgement that a sequence was parsed. Validate your output with the inspector if you want to be sure (DevTools is enabled in WebView2; press `F12` inside a terminal pane). +- Color values must be valid CSS hex (`#rgb` / `#rrggbb` / `#rrggbbaa`). Named colors and `rgb()` syntax are rejected. +- The terminating byte should be `BEL` or `ESC \`. xterm.js will eventually time out an unterminated OSC, but until then your text appears swallowed. + +## Pipeline (for CSM contributors) + +`terminal-init.js` registers the OSC handler via `term.parser.registerOscHandler(9001, …)`. The handler parses the payload, posts `{type: "shellIntegration", fields: {…}}` over the WebView2 message channel, and returns `true` so xterm consumes the sequence. `TerminalBridge.OnWebMessageReceived` raises `ShellIntegrationReceived`. `MainWindow.LaunchSessionAsync` subscribes and dispatches to `SessionViewModel.ApplyShellIntegration(fields)`, then triggers `SaveStateAsync`. Color/title changes propagate through `INotifyPropertyChanged` to repaint the sidebar stripe and active ring; git fields update `GitBranch` / `GitIsDirty`. From 3fd5919740904fb3506777b6cf13f30a1868e21b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 12:30:06 +0000 Subject: [PATCH 4/9] fix: convert #rrggbbaa to WPF #aarrggbb when applying OSC 9001 color Agent-Logs-Url: https://github.com/umage-ai/CodeShellManager/sessions/050b565f-3dd1-4e3a-b1d1-71a3ce9afdab Co-authored-by: AThraen <5888420+AThraen@users.noreply.github.com> --- CLAUDE.md | 2 +- docs/shell-integration.md | 2 +- .../ViewModels/SessionViewModel.cs | 23 +++++++++++++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7fea1af..c87208d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -283,7 +283,7 @@ ST may be `BEL` (`\x07`) or `ESC \\` — xterm.js accepts both. | Key | Effect | |---|---| -| `color` | Override the session accent (`#rrggbb` / `#rgb` / `#rrggbbaa`). Repaints sidebar stripe + active ring. | +| `color` | Override the session accent (`#rrggbb` / `#rgb` / `#rrggbbaa`). Repaints sidebar stripe + active ring. 8-digit values use alpha-last (`#rrggbbaa`); CSM converts to WPF's `#aarrggbb` internally. | | `git-branch` | Set `SessionViewModel.GitBranch` directly, bypassing `GitService`. | | `git-dirty` | `1`/`true` → dirty-marker shown; `0`/anything else → clean. | | `title` | Renames the session (calls `vm.Rename`). | diff --git a/docs/shell-integration.md b/docs/shell-integration.md index 20bf3ca..0616280 100644 --- a/docs/shell-integration.md +++ b/docs/shell-integration.md @@ -20,7 +20,7 @@ ESC ] 9001 ; key=value ; key=value … ST | Key | Value format | Effect | |--------------|-------------------------|--------| -| `color` | `#rgb`, `#rrggbb`, `#rrggbbaa` | Override the session accent. Repaints the sidebar stripe and the active-pane ring immediately. | +| `color` | `#rgb`, `#rrggbb`, `#rrggbbaa` | Override the session accent. Repaints the sidebar stripe and the active-pane ring immediately. 8-digit values use **alpha-last** (`#rrggbbaa`) — CSM converts them internally to WPF's `#aarrggbb` format. | | `git-branch` | string | Set the branch label shown in the sidebar. Bypasses CSM's local `git` polling — useful for SSH/remote sessions. | | `git-dirty` | `0`/`1` (or `false`/`true`) | Toggle the dirty marker (`*`) shown next to the branch. | | `title` | string | Rename the session (same as double-clicking the sidebar entry). Persisted to `state.json`. | diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs index 13d3f41..c18117d 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -129,7 +129,7 @@ private void OpenInExplorer() /// /// Applies a CSM shell-integration payload (OSC 9001) emitted by the running program. - /// Recognised keys: color (#rrggbb), git-branch, git-dirty (0/1), + /// Recognised keys: color (#rrggbb / #aarrggbb), git-branch, git-dirty (0/1), /// title. Unknown keys are ignored. Useful for SSH overlays whose remote /// state CSM cannot inspect locally. /// @@ -137,7 +137,9 @@ public void ApplyShellIntegration(System.Collections.Generic.IReadOnlyDictionary { if (fields.TryGetValue("color", out var color) && IsValidHexColor(color)) { - Session.ColorOverride = color; + // WPF ColorConverter.ConvertFromString interprets 8-digit hex as #AARRGGBB. + // Integrators emit #rrggbbaa (alpha last), so we reorder before storing. + Session.ColorOverride = ToWpfHexColor(color); OnPropertyChanged(nameof(AccentColor)); } @@ -163,6 +165,23 @@ private static bool IsValidHexColor(string s) return true; } + /// + /// Converts an integrator-supplied hex color to WPF format. + /// + /// 6-digit (#rrggbb) and 3-digit (#rgb) values are stored as-is. + /// 8-digit values use the integrator convention #rrggbbaa (alpha last), + /// but WPF's expects #AARRGGBB + /// (alpha first), so we reorder to #aarrggbb. + /// + /// + private static string ToWpfHexColor(string s) + { + // Only 8-digit (#rrggbbaa) needs reordering; 3- and 6-digit are fine as-is. + if (s.Length == 9) + return "#" + s[7..9] + s[1..7]; + return s; + } + public void RaiseAlert(string message, AlertType alertType = AlertType.InputRequired) { NeedsAttention = true; From 3fac7bc5af986b8db1a325f2fa17408e763ec049 Mon Sep 17 00:00:00 2001 From: Martin Ottosen Date: Mon, 11 May 2026 08:44:33 +0200 Subject: [PATCH 5/9] fix: silence local git poller once a session pushes git info via OSC --- .../ViewModels/SessionViewModel.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs index c18117d..a2b3c37 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -71,6 +71,15 @@ public string FolderShort private readonly CancellationTokenSource _gitPollCts = new(); + // Set by ApplyShellIntegration when the running program pushes git-branch + // or git-dirty via OSC 9001. Tells the local poller to stand down: the + // program is sourcing its own git state (e.g. `nexus ssh` into a container + // whose /workspace branch is unrelated to the host CWD) and the local + // poll would otherwise clobber the OSC value every 10s. Sticky for the + // lifetime of the session — once a program declares itself the source of + // truth, we trust it. + private bool _gitOverriddenByOsc; + public SessionViewModel(ShellSession session) { Session = session; @@ -81,7 +90,7 @@ public SessionViewModel(ShellSession session) public async Task RefreshGitInfoAsync() { - if (Session.IsRemote) return; + if (Session.IsRemote || _gitOverriddenByOsc) return; var (branch, isDirty) = await GitService.GetGitInfoAsync(Session.WorkingFolder); GitBranch = branch; GitIsDirty = isDirty; @@ -147,10 +156,14 @@ public void ApplyShellIntegration(System.Collections.Generic.IReadOnlyDictionary { GitBranch = string.IsNullOrWhiteSpace(branch) ? null : branch; GitInfoLoaded = true; + _gitOverriddenByOsc = true; } if (fields.TryGetValue("git-dirty", out var dirty)) + { GitIsDirty = dirty == "1" || string.Equals(dirty, "true", StringComparison.OrdinalIgnoreCase); + _gitOverriddenByOsc = true; + } if (fields.TryGetValue("title", out var title) && !string.IsNullOrWhiteSpace(title)) Rename(title.Trim()); From e2c25453d02190d4ffd58db49d165fefa0b75660 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 15:22:32 +0200 Subject: [PATCH 6/9] feat: wire OSC 9001 shell integration into MainWindow with a debounced save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forwards TerminalBridge.ShellIntegrationReceived to SessionViewModel.ApplyShellIntegration and coalesces the resulting saves through a new MainViewModel.SaveStateDebounced (500ms idle). This is the surviving part of the original wiring from PR #19. Its accent-repaint hunks are dropped: main now repaints the sidebar stripe and pane ring on every AccentColor change (worktree-sibling coloring), so they would have produced a duplicate case label. Its StateService serialization is dropped too — superseded by the atomic write + SaveGate in #88. Co-Authored-By: Martin Ottosen Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- src/CodeShellManager/MainWindow.xaml.cs | 9 +++++ .../ViewModels/MainViewModel.cs | 36 ++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index de82ffc..4c194f7 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -1243,6 +1243,15 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal bridge.RawOutputReceived += alertDetector.Feed; } + // Shell programs (e.g. an SSH overlay, a prompt hook, a Claude Code hook) push + // session state via OSC 9001. Apply it on the VM, then debounce-save so the + // accent/title persist without a state.json write per emission. + bridge.ShellIntegrationReceived += fields => + { + Dispatcher.Invoke(() => vm.ApplyShellIntegration(fields)); + _vm.SaveStateDebounced(); + }; + string assetsDir = Path.Combine(AppContext.BaseDirectory, "Assets"); bool wantTransparent = session.ProfileBackgroundOpacity is < 1.0; string htmlFile = wantTransparent ? "terminal-transparent.html" : "terminal.html"; diff --git a/src/CodeShellManager/ViewModels/MainViewModel.cs b/src/CodeShellManager/ViewModels/MainViewModel.cs index 62fb204..18c44fb 100644 --- a/src/CodeShellManager/ViewModels/MainViewModel.cs +++ b/src/CodeShellManager/ViewModels/MainViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.ObjectModel; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -20,11 +21,13 @@ public static class GroupFilter public const string AllKey = "__ALL__"; } -public partial class MainViewModel : ObservableObject +public partial class MainViewModel : ObservableObject, IDisposable { private readonly SessionManager _sessionManager; private readonly StateService _stateService; private AppState _appState = new(); + private System.Threading.Timer? _saveDebounceTimer; + private readonly object _timerLock = new(); public ObservableCollection Sessions { get; } = []; @@ -248,6 +251,28 @@ public async Task SaveStateAsync() await _stateService.SaveAsync(_appState); } + /// + /// Debounced save for high-frequency events (e.g., OSC 9001 shell integration). + /// Coalesces multiple rapid calls into a single write after 500ms of idle. + /// Thread-safe: uses lock to prevent race conditions on timer replacement. + /// + public void SaveStateDebounced() + { + lock (_timerLock) + { + _saveDebounceTimer?.Dispose(); + _saveDebounceTimer = new System.Threading.Timer( + _ => App.Current.Dispatcher.InvokeAsync(async () => + { + try { await SaveStateAsync(); } + catch { /* non-critical: state persistence failures (disk full, permissions) */ } + }), + null, + 500, + Timeout.Infinite); + } + } + public AppSettings Settings => _appState.Settings; /// Returns the current app state (after SaveStateAsync has been called to flush session data). @@ -512,4 +537,13 @@ private void HandleEffectiveGroupChanged() if (seeded || layoutSwitched) _ = SaveStateAsync(); } + + public void Dispose() + { + lock (_timerLock) + { + _saveDebounceTimer?.Dispose(); + _saveDebounceTimer = null; + } + } } From e7d4dbf73039101e9ef57a40f94d86bf95c5997d Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 15:33:52 +0200 Subject: [PATCH 7/9] fix(alert): strip OSC sequences terminated with ESC \ as well as BEL AlertDetector's ANSI regex only knew the BEL terminator. An ST-terminated OSC either leaked its payload into prompt matching or, worse, lazily swallowed real output up to the next BEL. Pre-existing, but OSC 9001 makes it hot: every example in docs/shell-integration.md ends in ESC \. Also accepts the `?` private-mode marker in CSI, matching OutputIndexer.AnsiPattern. StripAnsi is now internal so the regex can be tested directly. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- .../Services/AlertDetector.cs | 8 +++- .../AlertDetectorStripAnsiTests.cs | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 tests/CodeShellManager.Tests/AlertDetectorStripAnsiTests.cs diff --git a/src/CodeShellManager/Services/AlertDetector.cs b/src/CodeShellManager/Services/AlertDetector.cs index 8d4976f..01b6248 100644 --- a/src/CodeShellManager/Services/AlertDetector.cs +++ b/src/CodeShellManager/Services/AlertDetector.cs @@ -75,11 +75,15 @@ private void OnIdle(object? _) } } - private static string StripAnsi(string raw) => + internal static string StripAnsi(string raw) => s_ansi.Replace(raw, ""); + // OSC strings end in BEL (\x07) or ST (ESC \). Matching only BEL made an ST-terminated + // OSC either leak its payload into prompt matching or lazily swallow real output up to + // the next BEL. The `?` in the CSI class covers private-mode sequences (ESC[?25h). + // Same pattern as OutputIndexer.AnsiPattern — keep the two in step. private static readonly Regex s_ansi = - new(@"\x1B\[[0-9;]*[mGKHFJABCDsuhl]|\x1B\].*?\x07|\x1B[=>]", RegexOptions.Compiled); + new(@"\x1B\[[?0-9;]*[mGKHFJABCDsuhl]|\x1B\].*?(?:\x07|\x1B\\)|\x1B[=>]", RegexOptions.Compiled); // Matches Claude's "❯" prompt (U+276F), generic y/n prompts, and "?" questions private static readonly Regex s_prompt = diff --git a/tests/CodeShellManager.Tests/AlertDetectorStripAnsiTests.cs b/tests/CodeShellManager.Tests/AlertDetectorStripAnsiTests.cs new file mode 100644 index 0000000..2b7e536 --- /dev/null +++ b/tests/CodeShellManager.Tests/AlertDetectorStripAnsiTests.cs @@ -0,0 +1,42 @@ +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// is what the prompt regexes actually see. OSC strings +/// (title changes, hyperlinks, CSM's own OSC 9001) may end in either BEL or ST (ESC \); +/// both forms must be removed, or their payload leaks into prompt matching. +/// +public class AlertDetectorStripAnsiTests +{ + private static readonly string Esc = ((char)0x1b).ToString(); + private static readonly string Bel = ((char)0x07).ToString(); + private static readonly string St = Esc + "\\"; + + [Fact] + public void StripAnsi_OscTerminatedWithBel_IsRemoved() + => Assert.Equal("before after", + AlertDetector.StripAnsi("before " + Esc + "]0;window title" + Bel + "after")); + + [Fact] + public void StripAnsi_OscTerminatedWithSt_IsRemoved() + => Assert.Equal("before after", + AlertDetector.StripAnsi("before " + Esc + "]9001;color=#a6e3a1;title=demo?" + St + "after")); + + [Fact] + public void StripAnsi_StTerminatedOsc_DoesNotSwallowFollowingOutputUpToLaterBel() + { + // With a BEL-only regex the lazy match runs from the first OSC through the real text + // to the BEL that ends the *second* OSC — deleting "Do you want to proceed?" from what + // the prompt regex sees. + string raw = Esc + "]9001;git-branch=main" + St + + "Do you want to proceed?" + + Esc + "]0;window title" + Bel; + Assert.Equal("Do you want to proceed?", AlertDetector.StripAnsi(raw)); + } + + [Fact] + public void StripAnsi_CsiWithPrivateMarker_IsRemoved() + => Assert.Equal("x", AlertDetector.StripAnsi(Esc + "[?25hx")); +} From 4c624f4c3f336aaee417034de33044c21c567dec Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 15:33:52 +0200 Subject: [PATCH 8/9] feat(shell-integration): treat OSC 9001 values as untrusted; make colour resettable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything on this channel comes from whatever is printing to the terminal — a remote host, a `cat` of a file, a hook — and colour/title are persisted to state.json. Moves validation into a WPF-free ShellIntegrationPayload so it is unit-tested: - title: control characters stripped, trimmed, capped at 80 chars without splitting a surrogate pair; empty-after-cleanup leaves the existing name alone - git-branch: same stripping; empty → null (detached HEAD / not a repo) - colour: unchanged rules (#rgb/#rrggbb/#rrggbbaa → WPF #aarrggbb), now covered by tests ReloadGitInfoAsync resets the OSC git stand-down flag: after a folder edit the pushed info described the old folder, so the local poller gets to run again. OSC 9001 is the only writer of ColorOverride and the value survives sleep/wake and restart, so a session recoloured once had no way back. The sidebar context menu now offers "Reset accent color" whenever an override exists. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- src/CodeShellManager/MainWindow.xaml.cs | 14 +++ .../Services/ShellIntegrationPayload.cs | 76 +++++++++++++ .../ViewModels/SessionViewModel.cs | 50 ++++----- .../ShellIntegrationPayloadTests.cs | 102 ++++++++++++++++++ 4 files changed, 213 insertions(+), 29 deletions(-) create mode 100644 src/CodeShellManager/Services/ShellIntegrationPayload.cs create mode 100644 tests/CodeShellManager.Tests/ShellIntegrationPayloadTests.cs diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 4c194f7..3d2300e 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -3037,6 +3037,20 @@ private System.Windows.Controls.ContextMenu BuildSessionContextMenu(SessionViewM editItem.Click += async (_, _) => await EditSessionAsync(vm); menu.Items.Add(editItem); + // A program can recolour the session through OSC 9001 and the override persists + // across sleep/wake and restart. This is the only way to hand the colour back + // to the folder hash, so show it whenever an override exists. + if (vm.Session.ColorOverride is not null) + { + var resetColor = new System.Windows.Controls.MenuItem { Header = "Reset accent color" }; + resetColor.Click += (_, _) => + { + vm.ClearColorOverride(); + _ = _vm.SaveStateAsync(); + }; + menu.Items.Add(resetColor); + } + // 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/Services/ShellIntegrationPayload.cs b/src/CodeShellManager/Services/ShellIntegrationPayload.cs new file mode 100644 index 0000000..2266e3c --- /dev/null +++ b/src/CodeShellManager/Services/ShellIntegrationPayload.cs @@ -0,0 +1,76 @@ +using System; +using System.Text; + +namespace CodeShellManager.Services; + +/// +/// Validation and normalisation rules for the OSC 9001 shell-integration channel +/// (see docs/shell-integration.md). Kept WPF-free so the rules are unit-testable. +/// +/// Every value here is untrusted: it arrives from whatever is printing to the terminal — +/// a remote host, a cat of an arbitrary file, a hook — and some of it ends up +/// persisted in state.json. Reject what cannot be validated, trim and cap the rest. +/// +/// +public static class ShellIntegrationPayload +{ + /// Upper bound on a title pushed through OSC 9001. Long enough for any + /// sensible tab name; short enough that a runaway program can't bloat state.json + /// or the sidebar row. + public const int MaxTitleLength = 80; + + /// + /// Accepts #rgb, #rrggbb and #rrggbbaa. Returns the string in the + /// form WPF's ColorConverter expects: 3- and 6-digit values unchanged, 8-digit + /// values reordered from the integrator convention (alpha last) to #aarrggbb + /// (alpha first). Anything else — named colours, rgb(), wrong length, non-hex — + /// is rejected. + /// + public static bool TryNormalizeColor(string? input, out string? wpfHex) + { + wpfHex = null; + if (string.IsNullOrEmpty(input) || input[0] != '#') return false; + if (input.Length != 4 && input.Length != 7 && input.Length != 9) return false; + for (int i = 1; i < input.Length; i++) + if (!Uri.IsHexDigit(input[i])) return false; + + wpfHex = input.Length == 9 + ? "#" + input[7..9] + input[1..7] + : input; + return true; + } + + /// Only 1 and true (any case) mean dirty; everything else is clean. + public static bool ParseDirty(string? input) + => input == "1" || string.Equals(input, "true", StringComparison.OrdinalIgnoreCase); + + /// + /// Strips control characters, trims, and caps at without + /// splitting a surrogate pair. Returns null when nothing usable is left, so the + /// caller can leave the existing name alone rather than blank it. + /// + public static string? SanitizeTitle(string? input) + { + string? clean = StripControls(input); + if (clean is null) return null; + if (clean.Length <= MaxTitleLength) return clean; + + int cut = MaxTitleLength; + if (char.IsHighSurrogate(clean[cut - 1])) cut--; + return clean[..cut].TrimEnd(); + } + + /// Strips control characters and trims. Returns null for an empty result, + /// which the caller treats as "no branch" (detached HEAD, not a repo). + public static string? SanitizeBranch(string? input) => StripControls(input); + + private static string? StripControls(string? input) + { + if (string.IsNullOrWhiteSpace(input)) return null; + var sb = new StringBuilder(input.Length); + foreach (char c in input) + if (!char.IsControl(c)) sb.Append(c); + string result = sb.ToString().Trim(); + return result.Length == 0 ? null : result; + } +} diff --git a/src/CodeShellManager/ViewModels/SessionViewModel.cs b/src/CodeShellManager/ViewModels/SessionViewModel.cs index a2b3c37..6bc2bc0 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -144,55 +144,43 @@ private void OpenInExplorer() /// public void ApplyShellIntegration(System.Collections.Generic.IReadOnlyDictionary fields) { - if (fields.TryGetValue("color", out var color) && IsValidHexColor(color)) + // Every value is untrusted terminal output — validation lives in ShellIntegrationPayload. + if (fields.TryGetValue("color", out var color) + && ShellIntegrationPayload.TryNormalizeColor(color, out var wpfHex)) { - // WPF ColorConverter.ConvertFromString interprets 8-digit hex as #AARRGGBB. - // Integrators emit #rrggbbaa (alpha last), so we reorder before storing. - Session.ColorOverride = ToWpfHexColor(color); + Session.ColorOverride = wpfHex; OnPropertyChanged(nameof(AccentColor)); } if (fields.TryGetValue("git-branch", out var branch)) { - GitBranch = string.IsNullOrWhiteSpace(branch) ? null : branch; + GitBranch = ShellIntegrationPayload.SanitizeBranch(branch); GitInfoLoaded = true; _gitOverriddenByOsc = true; } if (fields.TryGetValue("git-dirty", out var dirty)) { - GitIsDirty = dirty == "1" || string.Equals(dirty, "true", StringComparison.OrdinalIgnoreCase); + GitIsDirty = ShellIntegrationPayload.ParseDirty(dirty); _gitOverriddenByOsc = true; } - if (fields.TryGetValue("title", out var title) && !string.IsNullOrWhiteSpace(title)) - Rename(title.Trim()); - } - - private static bool IsValidHexColor(string s) - { - if (string.IsNullOrEmpty(s) || s[0] != '#') return false; - if (s.Length != 4 && s.Length != 7 && s.Length != 9) return false; - for (int i = 1; i < s.Length; i++) - if (!Uri.IsHexDigit(s[i])) return false; - return true; + if (fields.TryGetValue("title", out var title) + && ShellIntegrationPayload.SanitizeTitle(title) is { } cleanTitle) + Rename(cleanTitle); } /// - /// Converts an integrator-supplied hex color to WPF format. - /// - /// 6-digit (#rrggbb) and 3-digit (#rgb) values are stored as-is. - /// 8-digit values use the integrator convention #rrggbbaa (alpha last), - /// but WPF's expects #AARRGGBB - /// (alpha first), so we reorder to #aarrggbb. - /// + /// Drops a so the accent falls back to the + /// hash-derived colour. OSC 9001 is currently the only writer of that field and it + /// persists across sleep/wake and restart, so without this a program that recoloured + /// a session once would own its colour forever. /// - private static string ToWpfHexColor(string s) + public void ClearColorOverride() { - // Only 8-digit (#rrggbbaa) needs reordering; 3- and 6-digit are fine as-is. - if (s.Length == 9) - return "#" + s[7..9] + s[1..7]; - return s; + if (Session.ColorOverride is null) return; + Session.ColorOverride = null; + OnPropertyChanged(nameof(AccentColor)); } public void RaiseAlert(string message, AlertType alertType = AlertType.InputRequired) @@ -239,6 +227,10 @@ public Task ReloadGitInfoAsync() GitIsDirty = false; GitInfoLoaded = false; HasWorktreeSiblings = false; + // The user just pointed this session at a different folder, so whatever program + // pushed git info via OSC 9001 was describing the old one. Let the local poller + // back in until a program re-declares itself. + _gitOverriddenByOsc = false; return RefreshGitInfoAsync(); } diff --git a/tests/CodeShellManager.Tests/ShellIntegrationPayloadTests.cs b/tests/CodeShellManager.Tests/ShellIntegrationPayloadTests.cs new file mode 100644 index 0000000..a01cd16 --- /dev/null +++ b/tests/CodeShellManager.Tests/ShellIntegrationPayloadTests.cs @@ -0,0 +1,102 @@ +using CodeShellManager.Services; +using Xunit; + +namespace CodeShellManager.Tests; + +/// +/// Tests for — the WPF-free rules behind OSC 9001. +/// The values arrive from whatever program is printing to the terminal (a remote host, +/// a `cat` of a file, a hook), so every field is treated as untrusted input. +/// +public class ShellIntegrationPayloadTests +{ + // ---- color --------------------------------------------------------------------------- + + [Theory] + [InlineData("#abc", "#abc")] + [InlineData("#a6e3a1", "#a6e3a1")] + [InlineData("#A6E3A1", "#A6E3A1")] + [InlineData("#a6e3a180", "#80a6e3a1")] // #rrggbbaa (integrator) → #aarrggbb (WPF) + public void TryNormalizeColor_ValidHex_ReturnsWpfForm(string input, string expected) + { + Assert.True(ShellIntegrationPayload.TryNormalizeColor(input, out var wpf)); + Assert.Equal(expected, wpf); + } + + [Theory] + [InlineData("")] + [InlineData("red")] + [InlineData("rgb(1,2,3)")] + [InlineData("a6e3a1")] // missing '#' + [InlineData("#a6e3a")] // 5 digits + [InlineData("#a6e3a1f")] // 7 digits + [InlineData("#gggggg")] // not hex + [InlineData("#a6e3a1;title=x")] + public void TryNormalizeColor_Invalid_ReturnsFalse(string input) + { + Assert.False(ShellIntegrationPayload.TryNormalizeColor(input, out var wpf)); + Assert.Null(wpf); + } + + // ---- git-dirty ----------------------------------------------------------------------- + + [Theory] + [InlineData("1", true)] + [InlineData("true", true)] + [InlineData("TRUE", true)] + [InlineData("0", false)] + [InlineData("false", false)] + [InlineData("", false)] + [InlineData("yes", false)] // only 1/true count as dirty + public void ParseDirty(string input, bool expected) + => Assert.Equal(expected, ShellIntegrationPayload.ParseDirty(input)); + + // ---- title --------------------------------------------------------------------------- + + [Theory] + [InlineData("my-repo", "my-repo")] + [InlineData(" padded ", "padded")] + [InlineData("tab\there", "tabhere")] // control chars stripped + [InlineData("esc\u001b[31mred", "esc[31mred")] // ESC stripped, printable remainder kept + [InlineData("multi\r\nline", "multiline")] + [InlineData("ünïcödé ⎇ ok", "ünïcödé ⎇ ok")] // non-ASCII printable is fine + public void SanitizeTitle_Printable_TrimsAndStripsControls(string input, string expected) + => Assert.Equal(expected, ShellIntegrationPayload.SanitizeTitle(input)); + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\u0007\u001b")] // nothing printable left + public void SanitizeTitle_NothingUsable_ReturnsNull(string input) + => Assert.Null(ShellIntegrationPayload.SanitizeTitle(input)); + + [Fact] + public void SanitizeTitle_LongTitle_IsCappedAtMaxTitleLength() + { + string input = new('x', ShellIntegrationPayload.MaxTitleLength + 50); + string? result = ShellIntegrationPayload.SanitizeTitle(input); + Assert.NotNull(result); + Assert.Equal(ShellIntegrationPayload.MaxTitleLength, result!.Length); + } + + [Fact] + public void SanitizeTitle_CapDoesNotSplitSurrogatePair() + { + // Fill up to one char short of the cap, then a 2-char emoji straddling the boundary. + string input = new string('x', ShellIntegrationPayload.MaxTitleLength - 1) + "😀"; + string? result = ShellIntegrationPayload.SanitizeTitle(input); + Assert.NotNull(result); + Assert.False(char.IsHighSurrogate(result![^1]), "cap must not leave a dangling high surrogate"); + } + + // ---- git-branch ---------------------------------------------------------------------- + + [Theory] + [InlineData("main", "main")] + [InlineData(" feat/x ", "feat/x")] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("a\u001bb", "ab")] + public void SanitizeBranch(string input, string? expected) + => Assert.Equal(expected, ShellIntegrationPayload.SanitizeBranch(input)); +} From feaaebfe0dd3b339586c7f961d4679cdfc3ac946 Mon Sep 17 00:00:00 2001 From: Allan Thraen Date: Sun, 6 Sep 2026 15:33:52 +0200 Subject: [PATCH 9/9] docs(shell-integration): document limits, reset, debounce and the untrusted-input rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `;` cannot appear in values (field separator, no escaping) — the one review comment on PR #19 that was never answered; recorded as a limitation rather than solved - title cap + control-char stripping, "Reset accent color", debounced save - CLAUDE.md: ShellIntegrationPayload in the services table; why MainWindow does no repainting of its own; why AlertDetector must strip both OSC terminators Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_015BEpUD83C7UHLMxNm8XByu --- CLAUDE.md | 15 ++++++++++++--- docs/shell-integration.md | 14 ++++---------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c87208d..33c2e95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,6 +78,7 @@ PTY (ConPTY) → PseudoTerminal → TerminalBridge → WebView2 (xterm.js) | `CursorShapeMapper` | WT `cursorShape` → xterm.js `cursorStyle` (+ optional forced blink) | | `PaddingParser` | WT `padding` shorthand (1/2/4 comma ints) → CSS `Npx` shorthand | | `CommandLineSplitter` | Helper — quote-aware split of a Windows commandline into `(exe, args)` | +| `ShellIntegrationPayload` | WPF-free validation for the OSC 9001 channel: hex-colour check + `#rrggbbaa`→`#aarrggbb`, dirty-flag parse, title/branch sanitising (control chars stripped, 80-char cap). See "Shell Integration (OSC 9001)" | ## Project Structure @@ -286,11 +287,19 @@ ST may be `BEL` (`\x07`) or `ESC \\` — xterm.js accepts both. | `color` | Override the session accent (`#rrggbb` / `#rgb` / `#rrggbbaa`). Repaints sidebar stripe + active ring. 8-digit values use alpha-last (`#rrggbbaa`); CSM converts to WPF's `#aarrggbb` internally. | | `git-branch` | Set `SessionViewModel.GitBranch` directly, bypassing `GitService`. | | `git-dirty` | `1`/`true` → dirty-marker shown; `0`/anything else → clean. | -| `title` | Renames the session (calls `vm.Rename`). | +| `title` | Renames the session (calls `vm.Rename`). Capped at `ShellIntegrationPayload.MaxTitleLength` (80), control chars stripped; empty-after-cleanup is ignored. | -Unknown keys are ignored. Multiple keys can be sent in a single sequence. +Unknown keys are ignored. Multiple keys can be sent in a single sequence. Values cannot contain `;` (the field separator, no escaping) — documented as a limitation rather than solved. -**Pipeline:** `terminal-init.js` registers an OSC handler via `term.parser.registerOscHandler(9001, …)` (requires `allowProposedApi: true`, already set). It posts `{type: "shellIntegration", fields: {…}}` to WPF. `TerminalBridge` parses it and raises `ShellIntegrationReceived`. `MainWindow.LaunchSessionAsync` subscribes and calls `vm.ApplyShellIntegration(fields)` on the dispatcher, then `SaveStateAsync` so changes persist. +**Every value is untrusted.** It comes from whatever is printing to the terminal — a remote host, a `cat` of some file, a hook — and `color`/`title` end up in `state.json`. All validation lives in the WPF-free `Services/ShellIntegrationPayload` (`TryNormalizeColor`, `ParseDirty`, `SanitizeTitle`, `SanitizeBranch`) so it is unit-tested (`ShellIntegrationPayloadTests`); `SessionViewModel.ApplyShellIntegration` only applies what that class accepts. + +**Git poller stand-down.** Once a session has received `git-branch` or `git-dirty`, `RefreshGitInfoAsync` stops touching `GitBranch`/`GitIsDirty` (`_gitOverriddenByOsc`) — otherwise the local CWD's state would clobber the pushed value every 10s. The flag is per-session-lifetime, not persisted, and `ReloadGitInfoAsync` (folder edit) resets it, since the pushed info described the old folder. + +**Colour is sticky, so it is resettable.** OSC 9001 is the only writer of `ShellSession.ColorOverride` and the override survives sleep/wake and restart. The sidebar right-click menu shows **Reset accent color** (→ `vm.ClearColorOverride()`) whenever an override exists. + +**AlertDetector must strip both OSC terminators.** Its ANSI regex originally matched only BEL-terminated OSC; every example in `docs/shell-integration.md` uses `ESC \`, which either leaked the payload into prompt matching or lazily swallowed real output up to the next BEL. It now mirrors `OutputIndexer.AnsiPattern` — keep the two in step (`AlertDetectorStripAnsiTests`). + +**Pipeline:** `terminal-init.js` registers an OSC handler via `term.parser.registerOscHandler(9001, …)` (requires `allowProposedApi: true`, already set). It posts `{type: "shellIntegration", fields: {…}}` to WPF. `TerminalBridge` parses it and raises `ShellIntegrationReceived`. `MainWindow.LaunchSessionAsync` subscribes and calls `vm.ApplyShellIntegration(fields)` on the dispatcher, then `MainViewModel.SaveStateDebounced()` (500ms idle coalescing — a prompt hook fires on every prompt, and a `state.json` write per emission would be silly). Repainting the stripe and ring is **not** done here: the existing `AccentColor` `PropertyChanged` subscriptions in `BuildSidebarItem` / `BuildTerminalWrapper` already handle it, exactly as they do when `RepoRoot` lands. The OSC handler returns `true` so xterm consumes the sequence and it doesn't render. diff --git a/docs/shell-integration.md b/docs/shell-integration.md index 0616280..12c9a8b 100644 --- a/docs/shell-integration.md +++ b/docs/shell-integration.md @@ -139,23 +139,17 @@ func csmUpdate(fields map[string]string) { **Update on relevant events only.** If a prompt-hook is too coarse — e.g. inside a long-running TUI like `nexus` — call your update function whenever your internal state changes (new repo selected, dirty state changes, branch checked out, etc.). -**Reset on exit.** If your program owns the session's accent for its lifetime, restore the default before exiting: - -```bash -# Clearing color sends the empty string, which CSM treats as "use the default hash" -# (only true if you've also chosen to clear ColorOverride; currently CSM keeps the -# last value. To restore the original hash, leave the color key out entirely.) -``` - -In the current build, an emitted `color=` is sticky and persists in `state.json` across restarts. If you want it to revert when your program exits, emit nothing extra — but if a different program later runs in the same session, it will inherit your color until it sets its own. +**Color is sticky.** An emitted `color=` is stored on the session and persists across sleep/wake and app restarts. There is no wire-level "reset" — an empty or invalid value is ignored, not applied. If a different program later runs in the same session it inherits your color until it sets its own. The user can hand the color back to the default folder hash at any time with **Reset accent color** in the session's right-click menu. ## Limitations - The protocol is one-way: CSM does not respond to OSC 9001 sequences with any data. - There's no acknowledgement that a sequence was parsed. Validate your output with the inspector if you want to be sure (DevTools is enabled in WebView2; press `F12` inside a terminal pane). - Color values must be valid CSS hex (`#rgb` / `#rrggbb` / `#rrggbbaa`). Named colors and `rgb()` syntax are rejected. +- **Values cannot contain `;`** — it is the field separator and there is no escaping. A `title=a;b` is read as `title=a` plus an unknown key `b`. `=` inside a value is fine (only the first `=` splits key from value). +- **Titles are capped at 80 characters.** Control characters are stripped, whitespace is trimmed, and a title that is empty after that is ignored (the existing name is kept). The same stripping applies to `git-branch`. - The terminating byte should be `BEL` or `ESC \`. xterm.js will eventually time out an unterminated OSC, but until then your text appears swallowed. ## Pipeline (for CSM contributors) -`terminal-init.js` registers the OSC handler via `term.parser.registerOscHandler(9001, …)`. The handler parses the payload, posts `{type: "shellIntegration", fields: {…}}` over the WebView2 message channel, and returns `true` so xterm consumes the sequence. `TerminalBridge.OnWebMessageReceived` raises `ShellIntegrationReceived`. `MainWindow.LaunchSessionAsync` subscribes and dispatches to `SessionViewModel.ApplyShellIntegration(fields)`, then triggers `SaveStateAsync`. Color/title changes propagate through `INotifyPropertyChanged` to repaint the sidebar stripe and active ring; git fields update `GitBranch` / `GitIsDirty`. +`terminal-init.js` registers the OSC handler via `term.parser.registerOscHandler(9001, …)`. The handler parses the payload, posts `{type: "shellIntegration", fields: {…}}` over the WebView2 message channel, and returns `true` so xterm consumes the sequence. `TerminalBridge.OnWebMessageReceived` raises `ShellIntegrationReceived`. `MainWindow.LaunchSessionAsync` subscribes and dispatches to `SessionViewModel.ApplyShellIntegration(fields)`, then calls `MainViewModel.SaveStateDebounced` (one write per 500ms of quiet, so a chatty prompt hook can't hammer `state.json`). Validation and normalisation of the untrusted values — hex check, `#rrggbbaa` → `#aarrggbb`, title cap, control-character stripping — live in the WPF-free `Services/ShellIntegrationPayload`, which is what the unit tests target. Color/title changes propagate through `INotifyPropertyChanged` to repaint the sidebar stripe and active ring; git fields update `GitBranch` / `GitIsDirty`.