Skip to content
25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,31 @@ 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.

> **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.

**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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`); 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
Expand Down
161 changes: 161 additions & 0 deletions docs/shell-integration.md
Original file line number Diff line number Diff line change
@@ -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::<Vec<_>>()
.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`.
19 changes: 19 additions & 0 deletions src/CodeShellManager/Assets/terminal-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment on lines +37 to +45
}
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 }));
Expand Down
32 changes: 32 additions & 0 deletions src/CodeShellManager/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
Comment on lines +349 to +355

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2aba7a7 and 8f801cf. Added SaveStateDebounced() to MainViewModel that coalesces rapid OSC 9001 events into a single write after 500ms idle. The timer callback uses Dispatcher.InvokeAsync for thread-safe state access, and a lock prevents race conditions on timer replacement. Also added SemaphoreSlim to StateService.SaveAsync() to serialize concurrent writes. Both MainViewModel and StateService now implement IDisposable to clean up resources properly.


string assetsDir = Path.Combine(AppContext.BaseDirectory, "Assets");
bool wantTransparent = session.ProfileBackgroundOpacity is < 1.0;
string htmlFile = wantTransparent ? "terminal-transparent.html" : "terminal.html";
Expand Down Expand Up @@ -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;
}
});
};
Expand Down Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions src/CodeShellManager/Terminal/TerminalBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ public sealed class TerminalBridge : IDisposable
public event Action<string>? RawOutputReceived;
public event Action? UserInput;

/// <summary>
/// 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, …).
/// </summary>
public event Action<System.Collections.Generic.IReadOnlyDictionary<string, string>>? ShellIntegrationReceived;

/// <summary>
/// Fires when the user presses a keyboard accelerator (Ctrl-combo, F-key, etc.)
/// while the WebView2 has focus. Subscribers set <c>e.Handled = true</c> to prevent
Expand Down Expand Up @@ -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<string, string>(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))
Expand Down
51 changes: 50 additions & 1 deletion src/CodeShellManager/ViewModels/SessionViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -99,6 +108,46 @@ private void OpenInExplorer()
System.Diagnostics.Process.Start("explorer.exe", Session.WorkingFolder);
}

/// <summary>
/// Applies a CSM shell-integration payload (OSC 9001) emitted by the running program.
/// Recognised keys: <c>color</c> (#rrggbb), <c>git-branch</c>, <c>git-dirty</c> (0/1),
/// <c>title</c>. Unknown keys are ignored. Useful for SSH overlays whose remote
/// state CSM cannot inspect locally.
/// </summary>
public void ApplyShellIntegration(System.Collections.Generic.IReadOnlyDictionary<string, string> 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;
_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());
}

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;
}
Comment on lines +144 to +151

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 70d4099. Added a ToWpfHexColor helper in SessionViewModel that converts 8-digit #rrggbbaa (alpha-last, integrator convention) to WPF's expected #aarrggbb (alpha-first) before storing. 3-digit and 6-digit values pass through unchanged. Both docs/shell-integration.md and CLAUDE.md now document this conversion so integrators know to emit alpha-last.


public void RaiseAlert(string message, AlertType alertType = AlertType.InputRequired)
{
NeedsAttention = true;
Expand Down
Loading