From 71d1517879295956fbe96ebb2a7f11259403a8cb Mon Sep 17 00:00:00 2001 From: Martin Ottosen Date: Sun, 10 May 2026 22:08:32 +0200 Subject: [PATCH 1/8] 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 f1ace5d..91d43cb 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 6f16ad7..b78b327 100644 --- a/src/CodeShellManager/Terminal/TerminalBridge.cs +++ b/src/CodeShellManager/Terminal/TerminalBridge.cs @@ -30,6 +30,12 @@ public sealed class TerminalBridge : IDisposable public event Action? RawOutputReceived; public event Action? UserInput; + /// + /// 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 @@ -230,6 +236,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 8b2b615..682ff60 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -99,6 +99,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 f83cb8816e59ef60fc58967ef95b0325e8100e0f Mon Sep 17 00:00:00 2001 From: Martin Ottosen Date: Sun, 10 May 2026 22:08:39 +0200 Subject: [PATCH 2/8] feat: wire shell-integration into MainWindow with live accent repaint --- src/CodeShellManager/MainWindow.xaml.cs | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index 34bb27c..e8f3449 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -346,6 +346,14 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal bridge.RawOutputReceived += alertDetector.Feed; } + // Shell programs (e.g. nexus over SSH) push session state via OSC 9001; + // forward those payloads to the VM, then save so accent/title persist. + bridge.ShellIntegrationReceived += fields => + { + Dispatcher.Invoke(() => vm.ApplyShellIntegration(fields)); + _ = _vm.SaveStateAsync(); + }; + string assetsDir = Path.Combine(AppContext.BaseDirectory, "Assets"); bool wantTransparent = session.ProfileBackgroundOpacity is < 1.0; string htmlFile = wantTransparent ? "terminal-transparent.html" : "terminal.html"; @@ -768,6 +776,15 @@ static void UpdateGitText(TextBlock tb, SessionViewModel svm) case nameof(SessionViewModel.GitInfoLoaded): UpdateGitText(gitText, vm); break; + + case nameof(SessionViewModel.AccentColor): + try + { + stripe.Background = new SolidColorBrush( + (Color)ColorConverter.ConvertFromString(vm.AccentColor)); + } + catch { } + break; } }); }; @@ -1304,6 +1321,21 @@ or nameof(SessionViewModel.IsWaitingForApproval)) } }); } + else if (args.PropertyName == nameof(SessionViewModel.AccentColor)) + { + Dispatcher.Invoke(() => + { + try + { + var c = (Color)ColorConverter.ConvertFromString(vm.AccentColor); + wrapper.BorderBrush = new SolidColorBrush(c); + activeRing.Tag = vm.AccentColor; + // If this session is active, re-apply the ring brush immediately + if (vm.IsActive) activeRing.BorderBrush = new SolidColorBrush(c); + } + catch { } + }); + } }; return activeRing; From 02f4c95a9a73094561e9b0010d1a24d0fe46eb30 Mon Sep 17 00:00:00 2001 From: Martin Ottosen Date: Sun, 10 May 2026 22:08:42 +0200 Subject: [PATCH 3/8] docs: document OSC 9001 shell integration --- CLAUDE.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index e76b6ce..2f243fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,6 +155,29 @@ When any override is set, `LaunchSessionAsync` calls `bridge.ApplyProfileOverrid **Once stamped, profile overrides are independent.** A session keeps its appearance even if the user later edits or deletes the source profile in Windows Terminal. +## 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 d362334a568d10072a254af5561be0c4679fc2cc Mon Sep 17 00:00:00 2001 From: Martin Ottosen Date: Sun, 10 May 2026 22:55:08 +0200 Subject: [PATCH 4/8] 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 2f243fb..1c0ae61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -159,6 +159,8 @@ When any override is set, `LaunchSessionAsync` calls `bridge.ApplyProfileOverrid 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 95c09d1..714221a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,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 - **Session history** — clicking a search result from a closed session offers to relaunch it 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 3ca1243ad15fe841df195956e63dc4f5da4dc7b2 Mon Sep 17 00:00:00 2001 From: Martin Ottosen Date: Mon, 11 May 2026 08:44:33 +0200 Subject: [PATCH 5/8] 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 682ff60..ccd4d3a 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -62,6 +62,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; @@ -71,7 +80,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; @@ -117,10 +126,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 70d40999a828d65ceb28dc50d5ce1f5c2376d497 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 6/8] 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 1c0ae61..b0f1f90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,7 +169,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 ccd4d3a..f4fd46a 100644 --- a/src/CodeShellManager/ViewModels/SessionViewModel.cs +++ b/src/CodeShellManager/ViewModels/SessionViewModel.cs @@ -110,7 +110,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. /// @@ -118,7 +118,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)); } @@ -148,6 +150,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 2aba7a7e3c61d36cb498f88068fe98b66e68abdc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 12:36:57 +0000 Subject: [PATCH 7/8] fix: debounce OSC 9001 saves and serialize StateService writes Agent-Logs-Url: https://github.com/umage-ai/CodeShellManager/sessions/7099de3e-461b-4286-ae83-7db99b33d7c3 Co-authored-by: AThraen <5888420+AThraen@users.noreply.github.com> --- src/CodeShellManager/MainWindow.xaml.cs | 4 +-- src/CodeShellManager/Services/StateService.cs | 17 +++++++-- .../ViewModels/MainViewModel.cs | 36 ++++++++++++++++++- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/CodeShellManager/MainWindow.xaml.cs b/src/CodeShellManager/MainWindow.xaml.cs index e8f3449..584da97 100644 --- a/src/CodeShellManager/MainWindow.xaml.cs +++ b/src/CodeShellManager/MainWindow.xaml.cs @@ -347,11 +347,11 @@ private async Task LaunchSessionAsync(ShellSession session, bool restoring = fal } // Shell programs (e.g. nexus over SSH) push session state via OSC 9001; - // forward those payloads to the VM, then save so accent/title persist. + // forward those payloads to the VM, then debounce-save so accent/title persist. bridge.ShellIntegrationReceived += fields => { Dispatcher.Invoke(() => vm.ApplyShellIntegration(fields)); - _ = _vm.SaveStateAsync(); + _vm.SaveStateDebounced(); }; string assetsDir = Path.Combine(AppContext.BaseDirectory, "Assets"); diff --git a/src/CodeShellManager/Services/StateService.cs b/src/CodeShellManager/Services/StateService.cs index 65c8980..73c4885 100644 --- a/src/CodeShellManager/Services/StateService.cs +++ b/src/CodeShellManager/Services/StateService.cs @@ -2,12 +2,13 @@ using System.IO; using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading; using System.Threading.Tasks; using CodeShellManager.Models; namespace CodeShellManager.Services; -public class StateService +public class StateService : IDisposable { private static string StatePath => Environment.GetEnvironmentVariable("CSM_STATE_PATH") @@ -21,6 +22,8 @@ public class StateService DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; + private readonly SemaphoreSlim _writeLock = new(1, 1); + /// Returns the resolved state file path (respects CSM_STATE_PATH env var). public static string GetPath() => StatePath; @@ -41,6 +44,7 @@ public async Task LoadAsync() public async Task SaveAsync(AppState state) { + await _writeLock.WaitAsync(); try { var path = StatePath; @@ -48,6 +52,15 @@ public async Task SaveAsync(AppState state) string json = JsonSerializer.Serialize(state, Options); await File.WriteAllTextAsync(path, json); } - catch { /* non-critical */ } + catch { /* non-critical: disk full, permissions, or serialization errors */ } + finally + { + _writeLock.Release(); + } + } + + public void Dispose() + { + _writeLock.Dispose(); } } diff --git a/src/CodeShellManager/ViewModels/MainViewModel.cs b/src/CodeShellManager/ViewModels/MainViewModel.cs index f43f0f8..81c14d4 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; @@ -12,11 +13,13 @@ namespace CodeShellManager.ViewModels; public enum LayoutMode { Single, TwoColumn, ThreeColumn, TwoByTwo, TwoRow, FourColumn, SixColumn, SixByTwo, SixByThree } -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; } = []; @@ -55,6 +58,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). @@ -180,4 +205,13 @@ public void MoveSession(string sessionId, int newIndex) if (cur != newIndex) Sessions.Move(cur, newIndex); _ = SaveStateAsync(); } + + public void Dispose() + { + lock (_timerLock) + { + _saveDebounceTimer?.Dispose(); + _saveDebounceTimer = null; + } + } } From 8f801cf614731d1430780bd74b4487572ba65195 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 12:38:17 +0000 Subject: [PATCH 8/8] fix: prevent SemaphoreFullException in StateService.SaveAsync Agent-Logs-Url: https://github.com/umage-ai/CodeShellManager/sessions/7099de3e-461b-4286-ae83-7db99b33d7c3 Co-authored-by: AThraen <5888420+AThraen@users.noreply.github.com> --- src/CodeShellManager/Services/StateService.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/CodeShellManager/Services/StateService.cs b/src/CodeShellManager/Services/StateService.cs index 73c4885..127d9e5 100644 --- a/src/CodeShellManager/Services/StateService.cs +++ b/src/CodeShellManager/Services/StateService.cs @@ -44,9 +44,11 @@ public async Task LoadAsync() public async Task SaveAsync(AppState state) { - await _writeLock.WaitAsync(); + bool acquired = false; try { + await _writeLock.WaitAsync(); + acquired = true; var path = StatePath; Directory.CreateDirectory(Path.GetDirectoryName(path)!); string json = JsonSerializer.Serialize(state, Options); @@ -55,7 +57,7 @@ public async Task SaveAsync(AppState state) catch { /* non-critical: disk full, permissions, or serialization errors */ } finally { - _writeLock.Release(); + if (acquired) _writeLock.Release(); } }