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
3 changes: 2 additions & 1 deletion .ai/contexts/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Context engineering — Switchboard

Seven sub-system docs (76 to 526 lines as of 2026-09, most have grown well past
Eight sub-system docs (76 to 526 lines as of 2026-09, most have grown well past
their original size), written for AI agents who need to make a focused change
without re-reading `main.js`, now ~2600 LOC.

Expand All @@ -16,6 +16,7 @@ without re-reading `main.js`, now ~2600 LOC.
| File-trigger watcher, harness input injection, idle-wait | [trigger-watcher](trigger-watcher.md) |
| Claude CLI state files, early subagent rescan, canary tests | [cli-session-state](cli-session-state.md) |
| Busy/attention/response-ready state, the session-state domain module, the icon-slot projection | [session-state](session-state.md) |
| The Changes panel: git-status parser, local/remote runner, cwd resolution, no-polling refresh | [changes-view](changes-view.md) |

## Reading order for a new contributor (~30 min)

Expand Down
99 changes: 99 additions & 0 deletions .ai/contexts/changes-view.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions .ai/contexts/ipc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ This file is the **canonical inventory** of the IPC surface. When you add a new
| `read-file-for-panel` / `save-file-for-panel` | Arbitrary file IO inside the user's projects |
| `watch-file` / `unwatch-file` | fs.watch wrapper, emits `file-changed` event |

### Changes panel (issue #251)

Read-only git-status view in the same right-hand file panel, for local and
remote sessions alike. Full design (parser, runner, quoting, cwd resolution,
refresh triggers): `.ai/contexts/changes-view.md`. User-facing: `docs/changes-view.md`.

| IPC | Args | Returns | Notes |
|---|---|---|---|
| `git-changes-status` | `(sessionId)` | `{ok, branch, files, totals} \| {ok:false, error}` | `git status --porcelain=v2 --branch` + `git diff --numstat` + `git diff --cached --numstat`, merged by `git-changes.js`'s `mergeChanges()`. |
| `git-changes-diff` | `(sessionId, filePath, staged)` | `{ok, content, truncated} \| {ok:false, error}` | `git diff [--cached] -- <filePath>`, capped at 512 KB. |

### Misc

| IPC | Notes |
Expand Down Expand Up @@ -145,6 +156,7 @@ Every handler that takes a renderer-supplied path or derives a spawn location fr
| `add-project` / `remap-project` | none on the probe (`fs.statSync`/`fs.existsSync`/`fs.lstatSync`); the actual write is confined through `encodeProjectPath` | existence/type oracle only — inherent to the feature (both accept an arbitrary disk location by design), not cheaply fixable without breaking it |
| `open-terminal` (`preLaunchCmd`) | `validatePreLaunchCmd` (`pre-launch-cmd-guard.js`) | not a path guard — a character allowlist on a raw-shell-by-design string (the documented prefix's character set plus its analogues: `env VAR=val`, `doas`, an absolute binary path); a denylist here proved incomplete (process substitution `<(...)`/`>(...)` needed none of the blocked characters), so this is closed by construction instead of by enumeration. Known cost: bare `$VAR` expansion and quoted arguments, both previously accepted, are now refused |
| `read-session-jsonl` / `read-subagent-jsonl` / `start-subagent-watch` / `create-schedule-session` | none directly — path is derived from a SQLite key or built via `encodeProjectPath`, not taken verbatim from the renderer | out of scope for a path guard; flag if a renderer-controlled string is ever found reaching the derivation unencoded |
| `git-changes-diff` | `isSafeGitPath` (`git-changes-runner.js`) | not a filesystem path — a git pathspec relative to an arbitrary (possibly remote) cwd; see `.ai/contexts/changes-view.md` ("Quoting rule") for why this is a denylist, not an allowlist |

### Non-obvious behaviors

Expand Down
8 changes: 8 additions & 0 deletions .ai/contexts/viewer-panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ The toolbar factory builds all configured buttons up front; `open()` toggles vis
- `public/file-panel.js` — has its own `fpViewerPanel = new ViewerPanel(...)` for the file-diff side panel; might need same opt
- If you add a new file-type-aware button, mirror the `_isJsonish()` / `_isMarkdown()` pattern with an `_isXyz()` helper rather than inlining the extension check

## Changes mode (issue #251)

`public/file-panel.js`'s side panel gained a third tab type, `'changes'`,
alongside the pre-existing `'file'` and `'diff'` (MCP) types on the same
per-session `filePanelState`. Full design (why it skips `ViewerPanel`, the
entry point, the no-polling refresh trigger): `.ai/contexts/changes-view.md`.
User-facing behavior: `docs/changes-view.md`.

## Gotchas

- **CodeMirror state holds DOM references** — calling `destroy()` then immediately `open()` on the SAME container works because `_createEditor` rebuilds it, but if you reorder this, the editor can dangle.
Expand Down
1 change: 1 addition & 0 deletions .ai/shared-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Switchboard is an **Electron desktop app**: renderer + main-process, no Domain/A
| Change busy/attention/response-ready state or the session-state domain module | [contexts/session-state.md](contexts/session-state.md) |
| Read the Claude CLI's own session state files | [contexts/cli-session-state.md](contexts/cli-session-state.md) |
| Change Memory/.work-files panels (CodeMirror) | [contexts/viewer-panel.md](contexts/viewer-panel.md) |
| Change the Changes panel (git-status parser, local/remote runner, cwd resolution) | [contexts/changes-view.md](contexts/changes-view.md) |
| Change the renderer (sidebar, terminal, app.js) | `public/*.js` — entry is `app.js` |
| Write a test | `test/*.test.js` — node:test + jsdom for renderer files |
| Working practices for AI agents (HANDOFF format, shell pitfalls, review loop) | [agent-practices.md](agent-practices.md) |
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This is the documentation for the [devsuitup/switchboard](https://github.com/dev
- [Terminal](terminal.md) — built-in terminal, right-click menu, drag-and-drop, in-terminal find
- [Grid Overview](grid-overview.md) — bird's-eye live grid of all open sessions
- [IDE Emulation](ide-emulation.md) — file diffs in a side panel, inline and side-by-side, partial accept
- [Changes View](changes-view.md) — read-only git-status panel, same for local and remote sessions
- [Subagents](subagents.md) — subagent index, hierarchy, live status, read-only transcript viewer
- [Session Restore](session-restore.md) — persist open sessions and restore them on restart
- [Keyboard Shortcuts](keyboard-shortcuts.md) — editor/terminal shortcuts and rebindable session-nav keys
Expand Down
37 changes: 37 additions & 0 deletions docs/changes-view.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Changes View

**Changes** is a read-only, git-status-sourced view of a session's working tree, shown in the same right-hand side panel as [IDE Emulation](ide-emulation.md)'s file/diff tabs. It exists because IDE-mode sessions never get the CLI's own `/diff` pane — Switchboard impersonates the IDE, and the IDE protocol never pushes "these files changed", only per-file diffs at permission time. A remote session shows `/diff` inside its terminal, but that view scrolls away with the session and isn't clickable from Switchboard. Changes gives both kinds the same panel.

## Opening it

Click the **Changes** button in the terminal header, next to the stop button. Click it again to close.

## What it shows

- A header line: `N files changed +A −B`, plus the current branch and how far it is ahead/behind its upstream.
- One row per changed file: a state letter (`M` modified, `A` added, `D` deleted, `R`/`C` renamed/copied, `?` untracked), its path, and its own `+added −deleted` line counts.
- Clicking a row opens a read-only diff for that file. Untracked files show a note instead of a diff — `git diff` never reports them.
- A **Refresh** button for a manual pull.

## What it doesn't do

- No staging, committing, or reverting from the UI — this is a viewer, not a git client.
- It doesn't replace the CLI's `/diff` pane in a non-IDE session; the two coexist.
- IDE mode itself is not available for remote sessions (that's a separate, larger feature — an `ssh -R` tunnel plus a lock file on the host); Changes does not depend on it and works today for both local and remote sessions.

## How it refreshes

Changes does not poll. It reloads:

- The moment you open it.
- When you click Refresh.
- The moment the session goes idle (finishes a turn) while the tab is open — for a remote session this costs one ssh round-trip, typically well under a second.

## Local vs. remote

The same parser and the same panel render both. Only the command runner differs:

- **Local**: `git status`/`git diff` run directly against the session's real working directory (its worktree, if it has one — the same directory a `claude --resume` targets).
- **Remote**: the same commands run over the existing ssh connection to the host, against the directory recorded in that session's descriptor. No attach, no tmux — this works even for a session you've never opened a terminal tab for.

Diffs are capped at 512 KB; a diff larger than that is truncated with a note at the bottom.
5 changes: 5 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ const rendererCrossFileGlobals = {
forgetActivitySeq: 'readonly',
purgeActivityFor: 'readonly',
pruneRemoteActivityTimers: 'readonly',
// Changes panel no-polling refresh hook (issue #251, public/file-panel.js)
onSessionIdle: 'readonly',
// public/session-state.js (pure domain, see .ai/contexts/session-state.md)
createSessionState: 'readonly',
renderSessionIcon: 'readonly',
Expand Down Expand Up @@ -455,6 +457,9 @@ module.exports = [
'read-session-file.js',
'derive-project-path.js',
'encode-project-path.js',
'git-changes.js',
'git-changes-runner.js',
'git-changes-target.js',
'folder-index-state.js',
'pty-size.js',
'claude-auth.js',
Expand Down
171 changes: 171 additions & 0 deletions git-changes-runner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// git-changes-runner.js — runs the git commands, local or remote — see .ai/contexts/changes-view.md

'use strict';

const { execFile } = require('child_process');
const { defaultRunRemoteCommand } = require('./remote-attach');
const { parseStatusPorcelainV2, parseNumstat, mergeChanges } = require('./git-changes');

const DEFAULT_LOCAL_TIMEOUT_MS = 10_000;
const DEFAULT_REMOTE_TIMEOUT_MS = 20_000;
const MAX_DIFF_BYTES = 512 * 1024;
const LOCAL_MAX_BUFFER = 20 * 1024 * 1024;
// Remote stdout caps — see .ai/contexts/changes-view.md ("Remote transport stdout cap").
const STATUS_MAX_STDOUT_BYTES = 2 * 1024 * 1024;
const DIFF_STDOUT_SLACK_BYTES = 64 * 1024;
const DIFF_MAX_STDOUT_BYTES = MAX_DIFF_BYTES + DIFF_STDOUT_SLACK_BYTES;

// Denylist, not allowlist — see .ai/contexts/changes-view.md ("Quoting rule")
function isSafeShellArg(s) {
return typeof s === 'string' && s.length > 0 && s.length <= 4096 && !/[\x00\n\r]/.test(s);
}

function isSafeCwd(cwd) {
return isSafeShellArg(cwd);
}

// Denylist plus a leading-':' shape check — see .ai/contexts/changes-view.md ("Quoting rule").
function isSafeGitPath(p) {
if (!isSafeShellArg(p)) return false;
if (p.includes('..')) return false;
if (p[0] === ':') return false;
return true;
}

// --literal-pathspecs on every invocation — see .ai/contexts/changes-view.md ("Quoting rule").
function buildGitArgs(args) {
return ['--literal-pathspecs', ...args];
}

// Cut on a line boundary at or under maxBytes, measured in UTF-8 bytes — see .ai/contexts/changes-view.md ("Runner interface")
function truncateDiffContent(content, maxBytes) {
if (Buffer.byteLength(content, 'utf8') <= maxBytes) return { content, truncated: false };
const lines = content.split('\n');
let acc = '';
let accBytes = 0;
for (let i = 0; i < lines.length; i++) {
const chunk = i < lines.length - 1 ? lines[i] + '\n' : lines[i];
const chunkBytes = Buffer.byteLength(chunk, 'utf8');
if (accBytes + chunkBytes > maxBytes) break;
acc += chunk;
accBytes += chunkBytes;
}
return { content: acc, truncated: true };
}

// POSIX single-quote escaping — see .ai/contexts/changes-view.md ("Quoting rule")
function shQuote(s) {
return "'" + String(s).replace(/'/g, "'\\''") + "'";
}

function buildRemoteGitCommand(cwd, args) {
return ['git', '-C', shQuote(cwd), ...args.map(shQuote)].join(' ');
}

// The session's cwd is authoritative: inherited repo-location vars must not redirect git — see .ai/contexts/changes-view.md
const GIT_LOCATION_ENV = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_OBJECT_DIRECTORY', 'GIT_PREFIX', 'GIT_NAMESPACE'];

function localGitEnv() {
const env = { ...process.env };
for (const k of GIT_LOCATION_ENV) delete env[k];
return env;
}

function defaultLocalExec(args, { cwd, timeoutMs }) {
return new Promise((resolve) => {
execFile('git', args, { cwd, env: localGitEnv(), timeout: timeoutMs, maxBuffer: LOCAL_MAX_BUFFER, windowsHide: true },
(err, stdout, stderr) => {
if (err) {
resolve({ code: typeof err.code === 'number' ? err.code : -1, stdout: stdout || '', stderr: stderr || err.message || String(err) });
return;
}
resolve({ code: 0, stdout: stdout || '', stderr: stderr || '' });
});
});
}

function firstError(result) {
return (result.stderr || '').trim() || `git exited with code ${result.code}`;
}

// {kind, cwd, alias, exec, timeoutMs} — see .ai/contexts/changes-view.md ("Runner interface")
function createGitChangesRunner({ kind, cwd, alias, exec, timeoutMs } = {}) {
if (kind !== 'local' && kind !== 'remote') {
throw new Error('createGitChangesRunner requires kind "local" or "remote"');
}
if (!isSafeCwd(cwd)) {
throw new Error('createGitChangesRunner requires a valid cwd');
}
if (kind === 'remote' && (typeof alias !== 'string' || !alias)) {
throw new Error('createGitChangesRunner requires an alias for a remote runner');
}

const effectiveTimeout = timeoutMs || (kind === 'local' ? DEFAULT_LOCAL_TIMEOUT_MS : DEFAULT_REMOTE_TIMEOUT_MS);

const runExec = exec || (kind === 'local'
? (args) => defaultLocalExec(args, { cwd, timeoutMs: effectiveTimeout })
: (command, remoteOpts) => defaultRunRemoteCommand(alias, command, {
timeoutMs: effectiveTimeout,
maxStdoutBytes: remoteOpts && remoteOpts.maxStdoutBytes,
}));

// remoteOpts (maxStdoutBytes) matter only for the remote transport — see .ai/contexts/changes-view.md ("Remote transport stdout cap")
function invoke(args, remoteOpts) {
const fullArgs = buildGitArgs(args);
return kind === 'local' ? runExec(fullArgs) : runExec(buildRemoteGitCommand(cwd, fullArgs), remoteOpts);
}

async function status() {
let results;
try {
results = await Promise.all([
invoke(['status', '--porcelain=v2', '--branch', '-z'], { maxStdoutBytes: STATUS_MAX_STDOUT_BYTES }),
invoke(['diff', '--numstat', '-z'], { maxStdoutBytes: STATUS_MAX_STDOUT_BYTES }),
invoke(['diff', '--cached', '--numstat', '-z'], { maxStdoutBytes: STATUS_MAX_STDOUT_BYTES }),
]);
} catch (err) {
return { ok: false, error: err.message };
}
const [st, unstagedNum, stagedNum] = results;
if (st.code !== 0) return { ok: false, error: firstError(st) };
if (unstagedNum.code !== 0) return { ok: false, error: firstError(unstagedNum) };
if (stagedNum.code !== 0) return { ok: false, error: firstError(stagedNum) };

const parsedStatus = parseStatusPorcelainV2(st.stdout);
const numstatUnstaged = parseNumstat(unstagedNum.stdout);
const numstatStaged = parseNumstat(stagedNum.stdout);
return { ok: true, ...mergeChanges(parsedStatus, numstatStaged, numstatUnstaged) };
}

async function diff(path, opts = {}) {
if (!isSafeGitPath(path)) return { ok: false, error: 'invalid path' };
const staged = !!opts.staged;
const args = staged ? ['diff', '--cached', '--', path] : ['diff', '--', path];

let result;
try {
result = await invoke(args, { maxStdoutBytes: DIFF_MAX_STDOUT_BYTES });
} catch (err) {
return { ok: false, error: err.message };
}
if (result.code !== 0) return { ok: false, error: firstError(result) };

const { content, truncated } = truncateDiffContent(result.stdout || '', MAX_DIFF_BYTES);
return { ok: true, content, truncated };
}

return { status, diff, kind, cwd, alias: alias || null };
}

module.exports = {
createGitChangesRunner,
buildRemoteGitCommand,
buildGitArgs,
truncateDiffContent,
shQuote,
isSafeCwd,
isSafeGitPath,
MAX_DIFF_BYTES,
STATUS_MAX_STDOUT_BYTES,
DIFF_MAX_STDOUT_BYTES,
};
43 changes: 43 additions & 0 deletions git-changes-target.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// git-changes-target.js — cwd resolution for the Changes panel IPCs — see .ai/contexts/changes-view.md

'use strict';

// Accepted sessionId shapes — see .ai/contexts/changes-view.md ("cwd resolution").
const PLAIN_SESSION_ID_RE = /^[A-Za-z0-9._-]+$/;
const PLACEHOLDER_SESSION_ID_RE = /^pid:[1-9][0-9]*$/;

function isValidChangesSessionId(id) {
if (typeof id !== 'string' || id === '') return false;
if (id === '.' || id === '..') return false;
if (PLAIN_SESSION_ID_RE.test(id)) return true;
return PLACEHOLDER_SESSION_ID_RE.test(id);
}

// deps: {getCachedFolder, isRemoteFolder, parseFolderKey, getRemoteSessions, activeSessions, resolveSessionRealCwd, existsSync, projectsDir}
function resolveGitChangesTarget(sessionId, deps) {
const id = String(sessionId || '');
if (!isValidChangesSessionId(id)) return { ok: false, error: 'invalid session id' };

let folder = null;
try { folder = deps.getCachedFolder(id); } catch {}

if (deps.isRemoteFolder(folder)) {
const { alias } = deps.parseFolderKey(folder);
const descriptor = deps.getRemoteSessions(alias).sessions.find((s) => s.sessionId === id);
const cwd = descriptor && typeof descriptor.cwd === 'string' ? descriptor.cwd : null;
if (!cwd) return { ok: false, error: 'remote session has no known working directory' };
return { ok: true, kind: 'remote', alias, cwd };
}

const session = deps.activeSessions.get(id);
if (session && !session.exited && session.cwd) {
return { ok: true, kind: 'local', cwd: session.cwd };
}
const realCwd = deps.resolveSessionRealCwd(deps.projectsDir, id, folder);
if (realCwd && deps.existsSync(realCwd)) {
return { ok: true, kind: 'local', cwd: realCwd };
}
return { ok: false, error: 'could not resolve a working directory for this session' };
}

module.exports = { resolveGitChangesTarget, isValidChangesSessionId };
Loading
Loading