Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ace4c2f
feat(changes): serve the content pair and the write of a changed file
jbr-sekoia Sep 17, 2026
3473a26
feat(changes): edit a changed file in place, with the diff recomputed…
jbr-sekoia Sep 17, 2026
d9e6228
fix(changes): never let a save overwrite what the session wrote
jbr-sekoia Sep 17, 2026
d0eb82a
fix(changes): tell the truth about the open file, and stop losing buf…
jbr-sekoia Sep 17, 2026
dd17c56
fix(codemirror): make the merge view an editing surface, not a viewer…
jbr-sekoia Sep 17, 2026
c16bc0f
docs(changes): state the staleness, encoding and containment rules th…
jbr-sekoia Sep 17, 2026
9255437
fix(changes): close the guards at the seams the reported cases did no…
jbr-sekoia Sep 18, 2026
a2efb7d
fix(changes): keep the buffer when the session takes the panel over
jbr-sekoia Sep 18, 2026
323d50d
test(codemirror): assert one save per keystroke in every editing mode
jbr-sekoia Sep 18, 2026
7df9d1e
docs(changes): state the resolved-path rule, the line-ending cases an…
jbr-sekoia Sep 18, 2026
5017231
fix(changes): honour a confirmed discard everywhere, not in two exits…
jbr-sekoia Sep 18, 2026
ea9e865
refactor(changes): drop the replaced helper and wire the watcher tear…
jbr-sekoia Sep 18, 2026
d3cfeba
fix(changes): drop a stashed buffer only when the user was asked abou…
jbr-sekoia Sep 18, 2026
fea54fb
test(changes): do not let a commented-out call satisfy a source asser…
jbr-sekoia Sep 18, 2026
56b8809
feat(changes): map a file link's absolute path to the row it belongs to
jbr-sekoia Sep 18, 2026
0a2fa40
feat(changes): keep the file list on screen while a file is open
jbr-sekoia Sep 18, 2026
15ebaf0
docs(changes): describe the master-detail layout and where a file lin…
jbr-sekoia Sep 18, 2026
a97f2d9
test(changes): cover the link fallback when the locate IPC throws
jbr-sekoia Sep 18, 2026
483512f
fix(changes): keep the change a save just wrote, and let Save show wh…
jbr-sekoia Sep 18, 2026
a6612be
style(panel): draw the panel and the sidebar from one set of values
jbr-sekoia Sep 18, 2026
6b37990
docs(changes): say when untracked counts reset, and which view opens …
jbr-sekoia Sep 18, 2026
9742996
test(changes): pin the three guards that were holding without being a…
jbr-sekoia Sep 18, 2026
68277b1
docs(changes): state what a link to a symlink resolves to, and bound …
jbr-sekoia Sep 18, 2026
874d644
fix(changes): refuse a hard link, and a string the write cannot repre…
jbr-sekoia Sep 18, 2026
43602a4
test(changes): let the scratch repo outlive a git child that is still…
jbr-sekoia Sep 18, 2026
66c5f41
docs(changes): state the hard-link rule and its cost, and the write's…
jbr-sekoia Sep 18, 2026
afca6c0
refactor(changes): keep the watcher's comment to a pointer
jbr-sekoia Sep 18, 2026
bbd6e5c
refactor(changes): keep the doc-change listener's comment to a pointer
jbr-sekoia Sep 18, 2026
6768b82
test(panel): cover the panel X over a dirty Changes buffer with a she…
jbr-sekoia Sep 18, 2026
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
279 changes: 272 additions & 7 deletions .ai/contexts/changes-view.md

Large diffs are not rendered by default.

19 changes: 15 additions & 4 deletions .ai/contexts/ipc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,23 @@ This file is the **canonical inventory** of the IPC surface. When you add a new

### 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`.
A git-status view in the same right-hand file panel, for local and remote
sessions alike; a local session's changed file is editable in place. Full
design (parser, runner, quoting, cwd resolution, refresh triggers, editing):
`.ai/contexts/changes-view.md`. User-facing: `docs/changes-view.md`.

| IPC | Args | Returns | Notes |
|---|---|---|---|
| `git-changes-status` | `(sessionId)` | `{ok, branch, files, totals, untrackedCollapsed} \| {ok:false, error}` | `git status --porcelain=v2 --branch -uall` + `git diff --numstat` + `git diff --cached --numstat`, merged by `git-changes.js`'s `mergeChanges()`. A `-uall` run too large for the transport falls back to git's default untracked mode and reports `untrackedCollapsed: true`. |
| `git-changes-status` | `(sessionId)` | `{ok, kind, branch, files, totals, untrackedCollapsed} \| {ok:false, error}` | `git status --porcelain=v2 --branch -uall` + `git diff --numstat` + `git diff --cached --numstat`, merged by `git-changes.js`'s `mergeChanges()`. A `-uall` run too large for the transport falls back to git's default untracked mode and reports `untrackedCollapsed: true`. `kind` is `'local'` or `'remote'` — the renderer decides from it whether the panel is editable. |
| `git-changes-diff` | `(sessionId, filePath, staged, untracked)` | `{ok, content, truncated, added, deleted} \| {ok:false, error}` | `git diff [--cached] -- <filePath>`, or `git diff --no-index -- /dev/null <filePath>` when `untracked`; capped at 512 KB. `added`/`deleted` are filled for an untracked file only — see `.ai/contexts/changes-view.md` ("Untracked files"). |
| `git-changes-file` | `(sessionId, filePath, {staged})` | `{ok, original, current, version, binary, truncated} \| {ok:false, error, reason}` | The content pair behind the editable diff: `git cat-file blob :<path>` (or `HEAD:<path>` when `staged`) and the working-tree file, both LF-normalised and strictly UTF-8. `version` is an opaque token the renderer hands back on save. Local sessions only. `reason` is one of `invalid-path`, `repo`, `missing`, `outside`, `symlink`, `hardlink`, `git-dir`, `sensitive`, `not-a-file`, `binary`, `too-large`, `encoding`, `mixed-eol`, `git`, `remote`. |
| `git-changes-save` | `(sessionId, filePath, content, version)` | `{ok:true, version} \| {ok:false, error, reason}` | Writes the working-tree file the guard resolved, re-applying its line endings, and returns the token for the next save. Refused with `reason:'stale'` when the file changed since `version` was issued, and with `invalid-version` when no token is passed. Local sessions only; never creates a file. |
| `git-changes-locate` | `(sessionId, filePath)` | `{ok:true, relPath, changed, staged, untracked} \| {ok:false, error, reason}` | The only Changes IPC that takes an **absolute** path, and it gives back a repo-relative row: a terminal file link maps to a Changes row here, never in the renderer. One `git status` scoped to that path decides `changed`. Local sessions only. |
| `git-changes-watch` / `git-changes-unwatch` | `(sessionId, filePath)` | `{ok:true} \| {ok:false, error, reason}` | `fs.watch` on the path the same guard resolves, through `git-changes-watch.js`'s registry, keyed by session + repo-relative path. Emits `git-changes-file-changed(sessionId, filePath)` (debounced 300 ms) — the repo-relative path, never the resolved one. A `rename` event re-arms the watch, since an atomic replacement otherwise silences it. |

`filePath` is repo-relative in all four. No absolute path crosses this
boundary in either direction: the session's cwd is re-resolved main-side on
every call, and the absolute path built from it is used and discarded there.

### Misc

Expand Down Expand Up @@ -157,6 +166,8 @@ 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-file` / `git-changes-watch` / `git-changes-unwatch` / `git-changes-locate` | `isSafeRevPathOperand` + `resolveTargetInsideRepo` (`git-changes-file.js`): the repo root and git directories come from `git rev-parse --show-toplevel --absolute-git-dir --git-common-dir`, a symlink at the target is refused before anything follows it, and **every remaining check runs on the disk-resolved path** — containment in the root, no `.git` segment, nothing inside a git directory, `isSensitivePath`, regular file | shape + disk-resolved containment + denylist — the operand is `<rev>:<path>`, a *revision*, not a pathspec: `--literal-pathspecs` does not reach it and `--` cannot separate it, so it carries its own guard. See `.ai/contexts/changes-view.md` ("Editing a changed file") |
| `git-changes-save` | `isSafeRepoRelativePath` + the same `resolveTargetInsideRepo`, plus a version token that must still match the bytes on disk; the write runs on the path the guard returned, never on a re-derived one | shape + disk-resolved containment + denylist — **the only write handler in the app whose entire input is a relative path from the renderer**, so containment is the guard, not an afterthought; `save-file-for-panel` next to it has none (it takes an absolute path and checks only `isSensitivePath`) and is not the precedent to copy here |
| `git-changes-diff` | `isSafeGitPath`, or `isSafeNoIndexPath` + containment when `untracked` (`git-changes-runner.js`) | 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. The untracked variant is a real filesystem operand of `git diff --no-index`, which has no repository-boundary check of its own: on top of the syntactic guard it is resolved with `realpath`/`stat` against the resolved cwd (local) or checked against `git ls-files --others` (remote), git receives the guard's operand rather than the caller's, and the returned diff must name that same path in its `diff --git` line — see "Untracked files" in the same doc |

### Non-obvious behaviors
Expand Down
56 changes: 55 additions & 1 deletion .ai/contexts/viewer-panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,63 @@ The toolbar factory builds all configured buttons up front; `open()` toggles vis
`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`.
entry point, the no-polling refresh trigger, editing): `.ai/contexts/changes-view.md`.
User-facing behavior: `docs/changes-view.md`.

A local session's selected file is edited in one of the same CodeMirror views
this component builds its own editors from — `createMergeViewer` (default),
`createUnifiedMergeViewer` or `createEditableViewer`, picked by a three-way
mode button and persisted under `localStorage.changesDiffMode` (the MCP diff
tab's `filePanelDiffMode` is a separate key with a separate meaning). Three
things about that editor are not `ViewerPanel`'s:

- **The tab owns the instance, not the panel.** It lives on `tab.editorView`
the way the MCP `'diff'` tab's does, keyed by path + staged + mode, and a
re-render reuses it instead of rebuilding — the Changes tab re-renders on
every busy→idle edge, which would otherwise land on the user's cursor.
`destroyCurrentTab()` and `closeChangesDiff()` are what end its life.
- **Reading the buffer back is asymmetric.** A side-by-side `MergeView` is read
from `view.b.state.doc`, the inline and plain views from `view.state.doc` —
the same asymmetry `handleDiffAction` already navigates for the MCP tab.
- **The save is an IPC by session, not by path**: `gitChangesSave(sessionId,
repoRelativePath, content, version)`, not `saveFileForPanel`. The renderer
never holds an absolute path for a Changes row, and the version token is what
stops it overwriting a file the session has written in the meantime.

`ViewerPanel`'s own protections have Changes-panel equivalents rather than
reuses, for the same reason: watching goes through `git-changes-watch` instead
of `watch-file` (session-keyed, no absolute path), and the in-flight save flag
lives on the tab instead of the component. One protection has no `ViewerPanel`
counterpart at all: an MCP-driven open replaces whatever tab is showing, so a
dirty Changes buffer is stashed on the session's panel state and restored when
the tab is reopened.

`Cmd/Ctrl+S` arrives as the same `cm-save` DOM event the bundle dispatches, and
the listener sits on `#changes-diff-view`, which is where `ViewerPanel` puts
its own (on its container).

The merge-view CSS in `public/style.css` is written for two hosts in one rule
list — `#file-panel-body` (MCP tab) and `#changes-diff-host` (Changes tab).
A new host means a new selector in those groups, not a copied block.

`createMergeViewer`'s `b` side and `createUnifiedMergeViewer` carry the editing
extensions a writing surface needs — `history()` (Ctrl/Cmd+Z), `defaultKeymap`,
`indentWithTab`, `indentOnInput`, `drawSelection` — and `cmSaveKeymap`, which is
what turns Ctrl/Cmd+S into the `cm-save` event the panels listen for. A
read-only viewer gets `cmSaveDomHandler` instead; an editable one must not have
both, or one keystroke raises two saves. `createUnifiedMergeViewer` takes
`{mergeControls}`: the MCP diff tab keeps the per-chunk Accept/Reject buttons,
the Changes panel turns them off. `test/codemirror-merge-editing.test.js`
drives all of this against the real CodeMirror under jsdom — a stub that
dispatches `cm-save` itself proves nothing about the keymap.

All three factories take an `onChange` callback, and `docChangeListener` in
`public/codemirror-setup.js` delivers it from a CodeMirror `updateListener` on
`docChanged` rather than from a DOM `input` listener on the editor: it fires
for typing, paste, undo/redo and programmatic dispatches alike, where a DOM
`input` event reports only the first two. A Save button whose enabled state is
computed from that callback is therefore still right after an undo.

## 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
37 changes: 32 additions & 5 deletions docs/changes-view.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
# 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.
**Changes** is a git-status-sourced view of a session's working tree — and, for a local session, an editor for the files in it — 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.

A file link in the terminal opens here too, when it points at one of this session's changed files: the panel opens on that row, ready to edit against its diff. A link to a file the session has not touched, or to one outside its repository, opens in the plain viewer as before.

## 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, including an untracked one — a brand-new file shows up as an all-additions diff. A binary file shows a one-line note instead of its bytes.
- Clicking a row opens that file below the list, which stays on screen — the current row is highlighted, and clicking another row swaps the file without going back anywhere. An untracked file opens too, as an all-additions diff. A binary file shows a one-line note instead of its bytes.
- Drag the divider between the list and the file to give either one more room; the position is remembered.
- A brand-new directory is listed file by file, not as a single folder row.
- A **Refresh** button for a manual pull.

Expand All @@ -34,8 +37,30 @@ row once: its diff is fetched, the row gets its `+added −0`, and the header
total grows by the same amount. This is deliberate — counting every new file up
front would mean running one extra git command per untracked file on every
refresh (and one ssh round-trip each, for a remote session), which a repo with a
large untracked tree would feel. Refreshing resets them, since the files may
have changed since.
large untracked tree would feel. A refresh resets them, since the file may have
changed since — with one exception: **saving the file you are editing keeps its
counts**, because the save is itself the measurement. The Refresh button, and a
refresh triggered by the session finishing a turn, reset them as before.

## Editing a file

On a local session, the open file is a live editor, not a picture of a diff. Type on the right-hand side and the diff recomputes as you go.

- **Save** with the Save button or `Ctrl/Cmd+S`. The button is inactive until you change something. The file list refreshes on save, so the row's counts follow what you wrote.
- The button next to **Close** cycles three views: **Inline** (one column, changes marked in place — the default, because the panel is a narrow column and side-by-side halves it), **Plain** (just the file, no diff decoration) and **Side-by-side** (the committed or staged version on the left, read-only; your working copy on the right). The choice is remembered.
- The left-hand side is what `git diff` compares against: the staged version for a row you opened staged, the last commit otherwise. What you see marked as changed is what git would report.
- These stay read-only, and the panel says which case it is: a remote session, a binary file, a file that is not UTF-8 text, a file that mixes line endings (no editor can keep them line by line), a symbolic link, a hard link (two names for the same bytes, and only one of them is in this repository), and a file over 2 MB.

### When the session writes the same file

The session you are watching writes these files, so the panel assumes it is not the only writer.

- While the file is open it is watched. If the session writes it and **your buffer has no unsaved edits**, the editor reloads to what is now on disk.
- If you **do** have unsaved edits, your buffer is left exactly as it is and the panel says the file changed on disk. **Reload** replaces it with the version on disk — it asks first, because that discards what you typed.
- A save of a file that changed since you opened it is **refused**, not merged and not forced: the panel tells you to reload first, and the session's work stays on disk. Saving again after a reload writes normally.
- Switching to another row, **Close** (which closes the file and keeps the list), closing the tab and closing the panel all ask before discarding unsaved edits.
- Whatever line ending the file uses is preserved — CRLF stays CRLF — so a save with no edits leaves git with nothing to report. A byte-order mark is kept too.
- If the session opens a file or a diff of its own while you have unsaved edits, the panel switches away without asking, but your edits are kept: reopening **Changes** brings them back and says why. Answering yes to a discard prompt is the opposite instruction, and it is honoured — nothing comes back afterwards.

## A shell under the list

Expand All @@ -48,7 +73,9 @@ sets how much room each gets. Local sessions only — see

## What it doesn't do

- No staging, committing, or reverting from the UI — this is a viewer, not a git client.
- No staging, committing, or reverting from the UI — you can type in it, but it is not a git client. Inline mode deliberately has no per-change accept/reject buttons.
- No creating, deleting or renaming files, and no editing on a remote session.
- Nothing under `.git/`, and no symbolic links.
- 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.

Expand Down
Loading
Loading