Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -269,6 +270,39 @@ 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.

> **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. 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`). 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. Values cannot contain `;` (the field separator, no escaping) — documented as a limitation rather than solved.

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

## 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 @@ -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 <id>`); 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
Expand Down
155 changes: 155 additions & 0 deletions docs/shell-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# 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. 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`. |

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

**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 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`.
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();
}
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
23 changes: 23 additions & 0 deletions src/CodeShellManager/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -3028,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))
{
Expand Down
8 changes: 6 additions & 2 deletions src/CodeShellManager/Services/AlertDetector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Loading