From e6acab85ad8969112bb514b7d5c49e06e5073775 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 26 Jun 2026 23:01:10 +0200 Subject: [PATCH 1/6] Add OSC-1717 draft spec --- diff-line-metadata-osc-spec.md | 489 +++++++++++++++++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 diff-line-metadata-osc-spec.md diff --git a/diff-line-metadata-osc-spec.md b/diff-line-metadata-osc-spec.md new file mode 100644 index 00000000000..3c0be628f75 --- /dev/null +++ b/diff-line-metadata-osc-spec.md @@ -0,0 +1,489 @@ +# Diff Line Metadata over OSC 1717 — draft specification (v1) + +**Status: draft, for feedback.** This document describes a small terminal +escape-sequence protocol by which a diff pager (delta, difftastic, diff-so-fancy, +…) annotates each rendered line of a diff with the patch-space identity it +represents, so that a host program rendering the pager's output can map a screen +row (and column) back to *the exact line in the underlying diff*. + +It is published to gather feedback from pager authors before anything is +finalized. The wire format, the negotiation handshake, and the OSC number are all +open to revision — §9 lists the points where feedback is most wanted. + +The protocol grew out of [lazygit](https://github.com/jesseduffield/lazygit), but +nothing in it is lazygit-specific; "the host" below means any program that runs a +pager and consumes its output. + +--- + +## 1. Motivation — what this enables, and why parsing isn't enough + +A host that shows a diff rendered by a pager often wants to act on the line the +user is pointing at: + +- **dive into staging / patch-building** for that hunk line, +- **open an editor** at that file and line, +- **open that line in a code-review / PR web view** (needs the side — old vs new), +- **navigate by hunk or by file** within the rendered diff, +- **preserve the scroll position and selection** across a re-render (the diff is + re-rendered with a different context size, or a different pager, and the host + wants to keep the user anchored on the same patch line). + +Every one of these needs the same primitive: **given a rendered row, recover +`(file, side, line)`** — the precise line of the unified diff that row stands for. + +For *structure-preserving* renderings (no pager, `git diff --color`, or +`delta --color-only` without line numbers) the host can recover this by parsing +the on-screen text: walk up to the nearest `@@` and `diff --git`, count `+`/` ` +lines, read the leading `+`/`-`/space. That works and needs no cooperation from +the pager. + +But the moment a pager **restructures** the diff, the unified-diff structure the +parse relies on is gone: + +- `delta` (default mode) and `diff-so-fancy` drop or hide the `+`/`-` markers and + convey the side with color, leaving no parseable unified-diff structure; +- `difftastic` is token-granular and side-by-side — there is no unified-diff line + structure left in *either* of its modes. + +In all of these the **pager is the only component that still knows** which file, +side, and line each rendered cell belongs to — it computed exactly that to render +the diff. This protocol asks the pager to *state* that knowledge inline, in a form +the host can read back and that is harmless everywhere else. + +--- + +## 2. Design at a glance + +1. The pager emits one OSC sequence carrying + `(version, type, new-line, old-line, file)` **immediately before** each + rendered region (a region is "one source line's worth of content in one + column" — see §6). +2. The host attaches each record to **the cell that follows it**, exactly the way + OSC-8 hyperlinks attach to the cells they precede. This makes the metadata + survive terminal wrapping and multi-column layouts **without the host ever + reasoning about layout** — it just reads the nearest preceding attachment. +3. The whole thing is gated behind an **environment-variable handshake**, so a + pager run outside a participating host (in a raw terminal, `less`, `tmux`, a + CI log) emits nothing and behaves byte-for-byte as before. + +The protocol is **layout-agnostic on the host side by construction**: all layout +knowledge (where a column starts, where a gutter ends, how a long line wraps) +stays in the pager, which is the only component that has it. + +--- + +## 3. Negotiation handshake + +``` +EMIT_OSC1717_METADATA = V1[,V2,…] +``` + +- The **host** sets this environment variable on the pager subprocess to the list + of protocol versions it understands, highest-preferred first is *not* required — + the list is a set. +- The **pager** emits the **highest version present in both** its own supported + set and the advertised set. If the variable is unset, empty, or shares no + version with the pager, the pager **emits nothing** and its output is unchanged. + +Why a handshake, and why it must exist in v1 even though v1's payload is tiny: + +- **It is the one piece that cannot be retrofitted.** Without negotiation you + cannot introduce a v2 later without a flag day. The payload itself is easy to + evolve *because* the handshake exists. +- **It guarantees zero cost when unwanted.** Outside a participating host the + variable is unset, so there is no output change to audit, no risk in a raw + terminal, no interference with `less`/`tmux`/pipelines. + +The variable name and the value grammar are themselves open to feedback (§9). + +--- + +## 4. Wire format (v1) + +### 4.1 The sequence + +``` +ESC ] 1717 ; ; ; ; ; ST +``` + +- `ESC` is `0x1B`; `ST` (String Terminator) is `ESC \` = `0x1B 0x5C`. A `BEL` + (`0x07`) terminator is also accepted, but `ST` is preferred — this matches the + framing of OSC-8 hyperlinks. +- The payload is **positional and `;`-delimited**. There are exactly five fields. +- `` is **last on purpose** so that it may itself contain `;`: the host + splits the payload into at most five fields and treats everything after the + fourth `;` as the path. + +Raw bytes, for a context line at new-file line 10 of `src/foo.go`: + +``` +\x1b]1717;1;c;10;;src/foo.go\x1b\ +``` + +### 4.2 Fields + +| field | presence | meaning | +|---|---|---| +| `version` | always | decimal protocol version; `1` for v1. Carried in every record so attachments are self-describing. | +| `type` | always | one character — see §5.1. v1 emits `c` (context), `a` (added), `d` (deleted). | +| `new-line` | always | new-file line number, in the **diff's new-file space** (see §5.2). | +| `old-line` | only `type=d` | old-file line number. **Empty** for `c` and `a`. | +| `file` | always | the file path the line belongs to; absolute or repo-root-relative (the host normalizes — emit whichever is convenient). Carried on **every** record so a single record is a complete answer. | + +For a **renamed** file, `file` is the **new** path (git's `+++ b/…` side); the old +path is not carried. A pure rename with no content change emits no records at all +(it has no content lines, only header rows). + +### 4.3 Examples + +| rendered line | emitted record (`;`-form) | +|---|---| +| context, new line 10 | `1717;1;c;10;;src/foo.go` | +| addition, new line 11 | `1717;1;a;11;;src/foo.go` | +| deletion, old line 9, sits at new pos 11 | `1717;1;d;11;9;src/foo.go` | +| two consecutive deletions | `…;d;11;9;…` then `…;d;11;10;…` (same `new-line`, different `old-line` — see §5.3) | +| whole-file deletion | `1717;1;d;0;9;old/path` (`new-line` 0 — see §5.4) | + +### 4.4 The handshake record + +A conforming pager emits, as the **very first thing it writes** and **once per run**, +a **version-only** record naming the version it negotiated: + +``` +ESC ] 1717 ; ST +``` + +i.e. the OSC introducer and the version field **with no further fields** — +`\x1b]1717;1\x1b\` for v1. It is emitted whenever the handshake (§3) negotiates a +version, *before* any diff content (and before the first per-line record). + +Its purpose is to let the host **probe** a pager cheaply and definitively: run it on an +**empty diff** (no changed content) and look for this record. Without it, "does this +pager speak the protocol?" could only be inferred from the per-line records — but a +diff with no content lines (a binary file, or the empty diff a probe would use) emits +none, so the absence of records would be indistinguishable from an unsupported pager. +The handshake is **content-independent** (it precedes, and does not depend on, any +diff), so a single probe is conclusive and a binary file can't be mistaken for an +unsupported pager. It also tells the host the negotiated version up front. + +A host distinguishes it from a per-line record (§4.1) by **field count**: the handshake +carries only the version (no `;` after it); a per-line record always has the full five +fields. A host that doesn't care about probing may simply ignore any record it can't +parse as five fields — so the handshake is harmless to existing parsers. + +--- + +## 5. Semantics + +### 5.1 Type + +`type` is one character. v1 defines three, all of them **content-line** types, and +a conforming pager emits one before every content line it renders: + +- `c` — context (unchanged) line +- `a` — added line +- `d` — deleted line + +**There is deliberately no file-header or hunk-header type — see §5.5.** The +protocol annotates content lines only; the host recovers file and hunk *structure* +from the content records themselves (the `file` field and `new-line` +discontinuities), and treats the pager's header/decoration rows as non-actionable. + +A host **must ignore a `type` it does not recognize** (treat the row as +non-actionable) rather than reject the record, so the set can grow later without a +version bump. + +**`type` is load-bearing and cannot be inferred from the other fields.** `added` +and `context` both carry `{new-line present, old-line absent}`, so presence alone +cannot distinguish them — yet the host must (scroll preservation anchors +specifically on *change* lines). Hence an explicit type. + +### 5.2 Line-number spaces + +- `new-line` is the line number in the **diff's new-file space** — i.e. the + new-file line numbering the diff itself uses, *not* necessarily the working-tree + file (the diff may be against staged content). A host that opens an editor is + expected to re-map this through its own diff↔worktree adjustment; the pager + should emit the number as it appears in the diff it is rendering. +- `old-line` is the old-file line number, present **only** for deletions. + +### 5.3 The deleted-line convention (both numbers) + +A `d` record carries **both** numbers: + +- `old-line` is the deletion's own old-file line number. +- `new-line` is the new-file position the deletion *sits at*: `newStart` plus the + number of added/context lines above it within the hunk. This is exactly what + `git`'s patch arithmetic already computes for a removed line. + +Consequence, which all pagers must implement identically: **two consecutive +deletions share the same `new-line`** (nothing new-file-side advances between +them) and are told apart only by `old-line`. Example — two deletions at old lines +9 and 10, both sitting at new position 11: + +``` +1717;1;d;11;9;src/foo.go +1717;1;d;11;10;src/foo.go +``` + +A host uses `old-line` to find a deletion's patch line and its old-side +(review/PR `L`) anchor, and `new-line` for the editor target. + +### 5.4 Whole-file add / delete + +A deleted file's lines carry `new-line` = `0` (mirroring git's `@@ -1,N +0,0 @@`); +an added file's lines carry the new-file numbers normally and `type=a`. + +### 5.5 Non-goal: header and decoration rows are not annotated + +The protocol covers **content lines only**. A pager's file-header and hunk-header +rows — delta's boxed file name and hunk-header box, difftastic's per-hunk banner, +diff-so-fancy's `── file ──` rule — carry **no** record, and there is no `f`/`h` +(or "file-header"/"hunk-header") type. The host treats every un-annotated row as +non-actionable. + +This is deliberate. The host does not need header records to recover diff +structure, because the structure is already implicit in the content records: + +- **File boundaries** — the `file` field changes between consecutive content + records, so the first content record carrying a new path *is* that file's entry. +- **Hunk boundaries** — `new-line` jumps by more than one between consecutive + content records of a file (lines were skipped), so a discontinuity marks a new + hunk. (Two consecutive deletions share a `new-line` by §5.3, so compute the gap + from the last *advancing* line.) + +So file/hunk navigation, "jump to the top of this file/hunk", and the rest are all +served by content records plus a trivial scan; the host lands navigation on a +hunk/file's first **content** row, backing up over any un-annotated header rows the +pager drew above it (a few lines of host code, needed anyway — see below). + +**Why not annotate headers, even optionally?** An earlier draft made `f`/`h` +mandatory; prototyping them in delta and difftastic (preserved in the design +notes) showed the cost is real and the benefit marginal: + +- It adds genuine pager-side friction. delta draws the file header when it parses + the `+++` line, *before* it has seen the first `@@`, so it cannot know the + header's hunk line without buffering or abandoning streaming. difftastic has no + separate header rows at all — one per-hunk banner is *both* a file and a hunk + header — so neither `f` nor `h` maps cleanly onto it. For a protocol whose whole + pitch is "emit one OSC per content line," this roughly doubles the conceptual + surface for the next pager author. +- Making them *optional* is the worst of both: a host can't rely on them, so it + must implement the "header row is un-annotated → back up to the nearest content + row" fallback regardless — and then maintain two code paths forever. Dropping + the types entirely leaves the host **one** path, exercised for every pager. + +The only thing genuinely lost is files that emit **no content records** at all — +pure renames, pure mode changes, binary files (§4.2). These become invisible to +the identity layer (navigation can't anchor on them). That is acceptable: they +have nothing to stage/edit/open, and remain reachable by ordinary cursor movement +over the rendered buffer. + +--- + +## 6. Emit rules (placement) + +The pager first emits the handshake record (§4.4) — once, before any other output — +then a per-line record before each region as follows. + +### 6.1 One record per region, at the region's start + +The pager emits each region's record at the **start of that region**. Everything +from there until the next region's record (or end of line) belongs to that +record — *including* any line-number gutter or other embellishment the pager +considers part of the region. Where a region "really" starts is the pager's +call, not the host's. The single firm requirement: + +> **The record must precede the region's first cell**, so that a host searching +> leftward from any cell lands in the correct region. + +### 6.2 Multiple regions per row (side-by-side) + +A side-by-side row shows two source lines at once, so it carries **two records**, +one before each column: + +- **left column → the old-side line** (`d`, carries `new-line` + `old-line`), +- **right column → the new-side line** (`a`, carries `new-line`), +- **a context line, shown in both columns → the same `c` record before each + half.** + +The blank counterpart of a pure add/delete (the empty half) carries **no** record. + +**v1 needs no column/side discriminator field for this.** `type` already implies +the column — `a` is inherently the new/right side, `d` the old/left side — and a +context line's two halves are the *same* logical line, so the host tells the two +`c` columns apart by position (which it does anyway). A side field would only earn +its keep to disambiguate the symmetric `c` case, and that case needs no +disambiguation. + +### 6.3 Wrapping — emit on every output row + +> **The pager emits a line's record at the start of *every output row* it produces +> for that line, including its own wrapped continuations.** + +There are two distinct kinds of wrapping, and the rule differs: + +- **Terminal/host wrapping** — the pager emits *one* line (one `\n`) and the + terminal (or the host's view) wraps it onto several visual rows. Here only the + primary row needs a record; the host's own row→line mapping routes every visual + row of that line back to it. A pager that relies on terminal wrapping emits one + record and is fine. +- **Pager wrapping** — the pager itself emits *several* lines (several `\n`s) for + one logical line, as difftastic's side-by-side does and as delta does in + side-by-side with `wrap-max-lines`. Now each wrapped row is a **distinct line** + to the host, with nothing tying row *N+1* back to row *N* — so each must carry + its own record, or it has none. + +A continuation row re-emits the *same* record as the primary line it continues +(no line-number advance). A column that has run out of wrapped content carries no +record on its padding rows. + +Getting this wrong is not theoretical: without per-row records, acting on a +wrapped continuation row does nothing, and hunk/file navigation breaks because the +untagged rows fragment a wrapped line into one block per visual row. + +### 6.4 Header and decoration rows — emit nothing + +A pager's header and decoration rows (file headers, hunk headers, dividers, +padding) carry **no** record — there is no header type to emit (§5.1, §5.5). The +host derives file and hunk structure from the content records and treats every +un-annotated row as non-actionable. + +--- + +## 7. How the host consumes it (informative) + +This section is not normative — it sketches the access model the per-cell carrier +is designed for, to make the emit rules concrete. + +- The host attaches each record to the following cell, like an OSC-8 hyperlink. If + a record has no following cell (a genuinely empty rendered line), the host adds + a content-less cell to hold it. +- **Row-granular action** (e.g. a keyboard "act on this line"): use the **first** + record on the row. In side-by-side this is the left column — fine, since the two + sides of a change are one hunk for staging purposes. +- **Point-granular action** (a mouse click): the per-cell attachment lets a host + use the **nearest record at or to the left of the click column**, landing in the + column actually clicked. (A host may equally resolve clicks at row granularity; + the carrier supports either.) +- The host normalizes `file` (resolving it relative to the repository working + tree) and otherwise treats the record as opaque identity. + +--- + +## 8. A known limitation and v2 candidate — feedback wanted + +This doesn't block v1; it's a place where input would shape a future version. + +**The token-vs-line model mismatch (difftastic, AST mode).** Our `c`/`a`/`d` set +is git's **line-granular** shape: a modified line is a `-` plus +a `+`. difftastic, when it parses the language (its **AST/token mode**), is finer — +it aligns lines and marks novelty per token. A line changed *only by added tokens* +(e.g. `println!("{}", x);` → `println!("{}", x + y);`) then has **no novelty on the +old side**, so difftastic renders that old line as context (dimmed, not red) and the +record faithfully says `c`, not `d`; only the new line gets `a`. (In difftastic's +line/Text fallback — e.g. an unparseable file — it diffs by line and emits `d`/`a` +as expected.) + +Because the record matches what difftastic shows, this isn't visible within +difftastic itself. The consequence is only for a host mapping cells back to git's +*line* diff: that old cell reports context at the new line, so its old-side `-` +identity isn't recoverable (impact small — `e` opens the right new-file line, and +users act on the changed side). A `modified`/`m` type — "aligned, changed, present +on both sides" — would name the case directly but splits the clean `c`/`a`/`d` +mapping; recorded as a v2 candidate, not taken (§9). + +--- + +## 9. Where feedback is most wanted + +1. **The OSC number, `1717`.** Chosen after auditing the OSC allocations of + xterm, VTE, kitty, foot, WezTerm, iTerm2, Windows Terminal, Ghostty, VS Code, + ConEmu and urxvt (see the appendix): `1717` is unused by all of them and sits + in the large empty 1400–5000 band (only iTerm2's `1337` is nearby). There is no + central registry, so this is "verified unused across the terminals that matter," + not "allocated." If you know of a terminal that interprets `1717`, please say so. +2. **The env-var name and grammar** (`EMIT_OSC1717_METADATA=V1,…`). +3. **The token-vs-line mismatch** (§8) — should there be an `m` type, or is + host-side inference the right home for it? +4. **Can your pager actually produce all four fields per region?** In particular + the side for deleted lines, and in side-by-side mode. (delta needed to track + its own old/new counters because its line-number counters are dormant unless + `--line-numbers` is on; difftastic had them natively. Your mileage may vary.) +5. **Content-lines-only scope (§5.5) — is anything lost for your pager?** We + deliberately dropped header annotations: an earlier draft made file/hunk-header + types mandatory, but prototyping them in delta and difftastic showed real + pager-side friction (delta draws the file header before it has parsed the first + `@@`; difftastic has no separate header rows, only a combined per-hunk banner) + for benefit the host can get by deriving structure from content records. If your + pager has a structure where the host genuinely *cannot* reconstruct file/hunk + boundaries from content records, tell us — that would argue for bringing header + types back. + +--- + +## 10. Reference implementations (prototype) + +Three pager emitters and one host carrier, all at prototype quality, emit or +consume the v1 format described here, over OSC `1717`: + +- **delta** — a dedicated additive emitter that injects only OSC bytes (no change + to styling, width, or wrapping); with the env var unset, output is byte-for-byte + identical to stock delta. Covers unified and side-by-side modes, including + wrapped rows. (A `f`/`h` header-record variant was also prototyped before headers + were dropped from the spec — see §5.5 / the design notes — but is not part of + this content-line-only protocol.) +- **difftastic** — the categorical case (#1 host-side parsing cannot serve it in + either mode). Emits the same v1 format under the same handshake; markedly less + code than delta because difftastic carries old/new line numbers natively. Covers + side-by-side and inline modes. +- **diff-so-fancy** — the same #2 case as delta's default (it strips the `+`/`-` + markers and conveys the side by color), but a line-oriented Perl filter rather + than a structured renderer, and the simplest of the three: unified single-column + only (no side-by-side). Classifies each line by its leading `+`/`-` before its + existing code strips that marker, tracking its own old/new counters seeded from + each `@@` header (like delta, it has no native line numbers). Combined/merge + diffs are not annotated. Because diff-so-fancy defensively strips terminal escape + sequences from the content it renders, the record is *prepended* to the line + rather than embedded in it. +- **host carrier** — gocui (lazygit's TUI library) accumulates the OSC number, + collects the payload, and stamps it per-cell like a hyperlink, cleared at each + line boundary so it cannot bleed onto an untagged following line. + +A key validated property in all three pagers: **with the handshake absent, output +is byte-identical to the unpatched pager** — the protocol is strictly additive. + +--- + +## Appendix — terminal OSC audit behind the `1717` choice + +There is no registry for OSC numbers; the de-facto convention is to pick a high, +distinctive number and verify no real terminal acts on it (an unknown OSC is +*skipped* by conformant terminals, but a *recognized* one could fire a visible +side-effect, e.g. `OSC 555` flashes foot and `OSC 777` raises a desktop +notification). The numbers below are interpreted by at least one surveyed terminal +and were therefore excluded: + +| OSC | meaning | actors | +|---|---|---| +| 0–3 | title / icon / X11 property | all | +| 4, 5, 6 | palette / special color / tab color | xterm, iTerm2 | +| 7, 8 | working directory, hyperlink | all major | +| 9 | notifications; ConEmu progress `9;4`, cwd `9;9` | iTerm2, kitty, WinTerm, foot | +| 10–19 | dynamic colors (fg/bg/cursor/mouse/Tek/highlight) | xterm, foot, VTE | +| 21, 22 | color query/set, pointer shape | kitty, foot | +| 46, 50, 51, 52 | logfile, font, Emacs, clipboard | xterm, iTerm2; 52 all major | +| 66 | text-sizing protocol | kitty, foot | +| 99 | desktop notifications | kitty | +| 104–106, 110–119 | reset colors | xterm, foot, VTE | +| 133 | semantic prompt / shell integration | iTerm2, kitty, foot, WezTerm, WinTerm | +| 176 | application id | foot | +| 555 | flash the terminal | foot | +| 633 | shell integration | VS Code | +| 777 | desktop notification (rxvt ext) | urxvt, VTE, foot, WezTerm | +| 1337 | proprietary namespace / file transfer | iTerm2, WezTerm | +| 5522 | extended clipboard | kitty | +| 30001 / 30101 | color-stack push / pop | kitty | + +`1717` collides with none of these and is far from every active cluster. From ed76cda074b77555774a3c248bb96806ad3f9ae8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 18:50:01 +0200 Subject: [PATCH 2/6] fixup! Add OSC-1717 draft spec Change env var name to OSC1717_METADATA. --- diff-line-metadata-osc-spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/diff-line-metadata-osc-spec.md b/diff-line-metadata-osc-spec.md index 3c0be628f75..f754786b74a 100644 --- a/diff-line-metadata-osc-spec.md +++ b/diff-line-metadata-osc-spec.md @@ -76,7 +76,7 @@ stays in the pager, which is the only component that has it. ## 3. Negotiation handshake ``` -EMIT_OSC1717_METADATA = V1[,V2,…] +OSC1717_METADATA = V1[,V2,…] ``` - The **host** sets this environment variable on the pager subprocess to the list @@ -404,7 +404,7 @@ mapping; recorded as a v2 candidate, not taken (§9). in the large empty 1400–5000 band (only iTerm2's `1337` is nearby). There is no central registry, so this is "verified unused across the terminals that matter," not "allocated." If you know of a terminal that interprets `1717`, please say so. -2. **The env-var name and grammar** (`EMIT_OSC1717_METADATA=V1,…`). +2. **The env-var name and grammar** (`OSC1717_METADATA=V1,…`). 3. **The token-vs-line mismatch** (§8) — should there be an `m` type, or is host-side inference the right home for it? 4. **Can your pager actually produce all four fields per region?** In particular From 821546b0d5c52c0931e51ca36bb66d4d6019f486 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 5 Jul 2026 09:58:04 +0200 Subject: [PATCH 3/6] fixup! Add OSC-1717 draft spec --- diff-line-metadata-osc-spec.md | 100 +++++++++++++++++---------------- 1 file changed, 53 insertions(+), 47 deletions(-) diff --git a/diff-line-metadata-osc-spec.md b/diff-line-metadata-osc-spec.md index f754786b74a..5e9041cdff4 100644 --- a/diff-line-metadata-osc-spec.md +++ b/diff-line-metadata-osc-spec.md @@ -1,24 +1,30 @@ # Diff Line Metadata over OSC 1717 — draft specification (v1) **Status: draft, for feedback.** This document describes a small terminal -escape-sequence protocol by which a diff pager (delta, difftastic, diff-so-fancy, +escape-sequence protocol by which a diff renderer (delta, difftastic, diff-so-fancy, …) annotates each rendered line of a diff with the patch-space identity it -represents, so that a host program rendering the pager's output can map a screen +represents, so that a host program rendering the diff renderer's output can map a screen row (and column) back to *the exact line in the underlying diff*. -It is published to gather feedback from pager authors before anything is +It is published to gather feedback from diff renderer authors before anything is finalized. The wire format, the negotiation handshake, and the OSC number are all open to revision — §9 lists the points where feedback is most wanted. The protocol grew out of [lazygit](https://github.com/jesseduffield/lazygit), but nothing in it is lazygit-specific; "the host" below means any program that runs a -pager and consumes its output. +diff renderer and consumes its output. + +About terminology: Lazygit has been using the term "pager" for what we call +"diff renderer" here. This is incorrect, a pager is something like less; but +it's unlikely to change soon, so be aware that the terms "pager" (or "custom +pager") and "diff renderer" can be used interchangeably in some discussions +about this. For the rest of this document, we avoid the term "pager" though. --- ## 1. Motivation — what this enables, and why parsing isn't enough -A host that shows a diff rendered by a pager often wants to act on the line the +A host that shows a diff rendered by a diff renderer often wants to act on the line the user is pointing at: - **dive into staging / patch-building** for that hunk line, @@ -26,19 +32,19 @@ user is pointing at: - **open that line in a code-review / PR web view** (needs the side — old vs new), - **navigate by hunk or by file** within the rendered diff, - **preserve the scroll position and selection** across a re-render (the diff is - re-rendered with a different context size, or a different pager, and the host + re-rendered with a different context size, or a different renderer, and the host wants to keep the user anchored on the same patch line). Every one of these needs the same primitive: **given a rendered row, recover `(file, side, line)`** — the precise line of the unified diff that row stands for. -For *structure-preserving* renderings (no pager, `git diff --color`, or +For *structure-preserving* renderings (no diff renderer, `git diff --color`, or `delta --color-only` without line numbers) the host can recover this by parsing the on-screen text: walk up to the nearest `@@` and `diff --git`, count `+`/` ` lines, read the leading `+`/`-`/space. That works and needs no cooperation from -the pager. +the renderer. -But the moment a pager **restructures** the diff, the unified-diff structure the +But the moment a renderer **restructures** the diff, the unified-diff structure the parse relies on is gone: - `delta` (default mode) and `diff-so-fancy` drop or hide the `+`/`-` markers and @@ -46,16 +52,16 @@ parse relies on is gone: - `difftastic` is token-granular and side-by-side — there is no unified-diff line structure left in *either* of its modes. -In all of these the **pager is the only component that still knows** which file, +In all of these the **diff renderer is the only component that still knows** which file, side, and line each rendered cell belongs to — it computed exactly that to render -the diff. This protocol asks the pager to *state* that knowledge inline, in a form +the diff. This protocol asks the renderer to *state* that knowledge inline, in a form the host can read back and that is harmless everywhere else. --- ## 2. Design at a glance -1. The pager emits one OSC sequence carrying +1. The diff renderer emits one OSC sequence carrying `(version, type, new-line, old-line, file)` **immediately before** each rendered region (a region is "one source line's worth of content in one column" — see §6). @@ -64,12 +70,12 @@ the host can read back and that is harmless everywhere else. survive terminal wrapping and multi-column layouts **without the host ever reasoning about layout** — it just reads the nearest preceding attachment. 3. The whole thing is gated behind an **environment-variable handshake**, so a - pager run outside a participating host (in a raw terminal, `less`, `tmux`, a + renderer run outside a participating host (in a raw terminal, `less`, `tmux`, a CI log) emits nothing and behaves byte-for-byte as before. The protocol is **layout-agnostic on the host side by construction**: all layout knowledge (where a column starts, where a gutter ends, how a long line wraps) -stays in the pager, which is the only component that has it. +stays in the diff renderer, which is the only component that has it. --- @@ -79,12 +85,12 @@ stays in the pager, which is the only component that has it. OSC1717_METADATA = V1[,V2,…] ``` -- The **host** sets this environment variable on the pager subprocess to the list +- The **host** sets this environment variable on the diff renderer subprocess to the list of protocol versions it understands, highest-preferred first is *not* required — the list is a set. -- The **pager** emits the **highest version present in both** its own supported +- The **diff renderer** emits the **highest version present in both** its own supported set and the advertised set. If the variable is unset, empty, or shares no - version with the pager, the pager **emits nothing** and its output is unchanged. + version with the renderer, the renderer **emits nothing** and its output is unchanged. Why a handshake, and why it must exist in v1 even though v1's payload is tiny: @@ -147,7 +153,7 @@ path is not carried. A pure rename with no content change emits no records at al ### 4.4 The handshake record -A conforming pager emits, as the **very first thing it writes** and **once per run**, +A conforming diff renderer emits, as the **very first thing it writes** and **once per run**, a **version-only** record naming the version it negotiated: ``` @@ -158,14 +164,14 @@ i.e. the OSC introducer and the version field **with no further fields** — `\x1b]1717;1\x1b\` for v1. It is emitted whenever the handshake (§3) negotiates a version, *before* any diff content (and before the first per-line record). -Its purpose is to let the host **probe** a pager cheaply and definitively: run it on an +Its purpose is to let the host **probe** a diff renderer cheaply and definitively: run it on an **empty diff** (no changed content) and look for this record. Without it, "does this -pager speak the protocol?" could only be inferred from the per-line records — but a +renderer speak the protocol?" could only be inferred from the per-line records — but a diff with no content lines (a binary file, or the empty diff a probe would use) emits -none, so the absence of records would be indistinguishable from an unsupported pager. +none, so the absence of records would be indistinguishable from an unsupported renderer. The handshake is **content-independent** (it precedes, and does not depend on, any diff), so a single probe is conclusive and a binary file can't be mistaken for an -unsupported pager. It also tells the host the negotiated version up front. +unsupported renderer. It also tells the host the negotiated version up front. A host distinguishes it from a per-line record (§4.1) by **field count**: the handshake carries only the version (no `;` after it); a per-line record always has the full five @@ -179,7 +185,7 @@ parse as five fields — so the handshake is harmless to existing parsers. ### 5.1 Type `type` is one character. v1 defines three, all of them **content-line** types, and -a conforming pager emits one before every content line it renders: +a conforming diff renderer emits one before every content line it renders: - `c` — context (unchanged) line - `a` — added line @@ -188,7 +194,7 @@ a conforming pager emits one before every content line it renders: **There is deliberately no file-header or hunk-header type — see §5.5.** The protocol annotates content lines only; the host recovers file and hunk *structure* from the content records themselves (the `file` field and `new-line` -discontinuities), and treats the pager's header/decoration rows as non-actionable. +discontinuities), and treats the renderer's header/decoration rows as non-actionable. A host **must ignore a `type` it does not recognize** (treat the row as non-actionable) rather than reject the record, so the set can grow later without a @@ -204,7 +210,7 @@ specifically on *change* lines). Hence an explicit type. - `new-line` is the line number in the **diff's new-file space** — i.e. the new-file line numbering the diff itself uses, *not* necessarily the working-tree file (the diff may be against staged content). A host that opens an editor is - expected to re-map this through its own diff↔worktree adjustment; the pager + expected to re-map this through its own diff↔worktree adjustment; the renderer should emit the number as it appears in the diff it is rendering. - `old-line` is the old-file line number, present **only** for deletions. @@ -217,7 +223,7 @@ A `d` record carries **both** numbers: number of added/context lines above it within the hunk. This is exactly what `git`'s patch arithmetic already computes for a removed line. -Consequence, which all pagers must implement identically: **two consecutive +Consequence, which all diff renderers must implement identically: **two consecutive deletions share the same `new-line`** (nothing new-file-side advances between them) and are told apart only by `old-line`. Example — two deletions at old lines 9 and 10, both sitting at new position 11: @@ -237,7 +243,7 @@ an added file's lines carry the new-file numbers normally and `type=a`. ### 5.5 Non-goal: header and decoration rows are not annotated -The protocol covers **content lines only**. A pager's file-header and hunk-header +The protocol covers **content lines only**. A diff renderer's file-header and hunk-header rows — delta's boxed file name and hunk-header box, difftastic's per-hunk banner, diff-so-fancy's `── file ──` rule — carry **no** record, and there is no `f`/`h` (or "file-header"/"hunk-header") type. The host treats every un-annotated row as @@ -256,23 +262,23 @@ structure, because the structure is already implicit in the content records: So file/hunk navigation, "jump to the top of this file/hunk", and the rest are all served by content records plus a trivial scan; the host lands navigation on a hunk/file's first **content** row, backing up over any un-annotated header rows the -pager drew above it (a few lines of host code, needed anyway — see below). +renderer drew above it (a few lines of host code, needed anyway — see below). **Why not annotate headers, even optionally?** An earlier draft made `f`/`h` mandatory; prototyping them in delta and difftastic (preserved in the design notes) showed the cost is real and the benefit marginal: -- It adds genuine pager-side friction. delta draws the file header when it parses +- It adds genuine renderer-side friction. delta draws the file header when it parses the `+++` line, *before* it has seen the first `@@`, so it cannot know the header's hunk line without buffering or abandoning streaming. difftastic has no separate header rows at all — one per-hunk banner is *both* a file and a hunk header — so neither `f` nor `h` maps cleanly onto it. For a protocol whose whole pitch is "emit one OSC per content line," this roughly doubles the conceptual - surface for the next pager author. + surface for the next diff renderer author. - Making them *optional* is the worst of both: a host can't rely on them, so it must implement the "header row is un-annotated → back up to the nearest content row" fallback regardless — and then maintain two code paths forever. Dropping - the types entirely leaves the host **one** path, exercised for every pager. + the types entirely leaves the host **one** path, exercised for every diff renderer. The only thing genuinely lost is files that emit **no content records** at all — pure renames, pure mode changes, binary files (§4.2). These become invisible to @@ -284,15 +290,15 @@ over the rendered buffer. ## 6. Emit rules (placement) -The pager first emits the handshake record (§4.4) — once, before any other output — +The diff renderer first emits the handshake record (§4.4) — once, before any other output — then a per-line record before each region as follows. ### 6.1 One record per region, at the region's start -The pager emits each region's record at the **start of that region**. Everything +The diff renderer emits each region's record at the **start of that region**. Everything from there until the next region's record (or end of line) belongs to that -record — *including* any line-number gutter or other embellishment the pager -considers part of the region. Where a region "really" starts is the pager's +record — *including* any line-number gutter or other embellishment the renderer +considers part of the region. Where a region "really" starts is the renderer's call, not the host's. The single firm requirement: > **The record must precede the region's first cell**, so that a host searching @@ -319,17 +325,17 @@ disambiguation. ### 6.3 Wrapping — emit on every output row -> **The pager emits a line's record at the start of *every output row* it produces +> **The diff renderer emits a line's record at the start of *every output row* it produces > for that line, including its own wrapped continuations.** There are two distinct kinds of wrapping, and the rule differs: -- **Terminal/host wrapping** — the pager emits *one* line (one `\n`) and the +- **Terminal/host wrapping** — the renderer emits *one* line (one `\n`) and the terminal (or the host's view) wraps it onto several visual rows. Here only the primary row needs a record; the host's own row→line mapping routes every visual - row of that line back to it. A pager that relies on terminal wrapping emits one + row of that line back to it. A renderer that relies on terminal wrapping emits one record and is fine. -- **Pager wrapping** — the pager itself emits *several* lines (several `\n`s) for +- **Diff renderer wrapping** — the renderer itself emits *several* lines (several `\n`s) for one logical line, as difftastic's side-by-side does and as delta does in side-by-side with `wrap-max-lines`. Now each wrapped row is a **distinct line** to the host, with nothing tying row *N+1* back to row *N* — so each must carry @@ -345,7 +351,7 @@ untagged rows fragment a wrapped line into one block per visual row. ### 6.4 Header and decoration rows — emit nothing -A pager's header and decoration rows (file headers, hunk headers, dividers, +A diff renderer's header and decoration rows (file headers, hunk headers, dividers, padding) carry **no** record — there is no header type to emit (§5.1, §5.5). The host derives file and hunk structure from the content records and treats every un-annotated row as non-actionable. @@ -407,17 +413,17 @@ mapping; recorded as a v2 candidate, not taken (§9). 2. **The env-var name and grammar** (`OSC1717_METADATA=V1,…`). 3. **The token-vs-line mismatch** (§8) — should there be an `m` type, or is host-side inference the right home for it? -4. **Can your pager actually produce all four fields per region?** In particular +4. **Can your diff renderer actually produce all four fields per region?** In particular the side for deleted lines, and in side-by-side mode. (delta needed to track its own old/new counters because its line-number counters are dormant unless `--line-numbers` is on; difftastic had them natively. Your mileage may vary.) -5. **Content-lines-only scope (§5.5) — is anything lost for your pager?** We +5. **Content-lines-only scope (§5.5) — is anything lost for your diff renderer?** We deliberately dropped header annotations: an earlier draft made file/hunk-header types mandatory, but prototyping them in delta and difftastic showed real - pager-side friction (delta draws the file header before it has parsed the first + renderer-side friction (delta draws the file header before it has parsed the first `@@`; difftastic has no separate header rows, only a combined per-hunk banner) for benefit the host can get by deriving structure from content records. If your - pager has a structure where the host genuinely *cannot* reconstruct file/hunk + renderer has a structure where the host genuinely *cannot* reconstruct file/hunk boundaries from content records, tell us — that would argue for bringing header types back. @@ -425,7 +431,7 @@ mapping; recorded as a v2 candidate, not taken (§9). ## 10. Reference implementations (prototype) -Three pager emitters and one host carrier, all at prototype quality, emit or +Three diff renderer emitters and one host carrier, all at prototype quality, emit or consume the v1 format described here, over OSC `1717`: - **delta** — a dedicated additive emitter that injects only OSC bytes (no change @@ -451,8 +457,8 @@ consume the v1 format described here, over OSC `1717`: collects the payload, and stamps it per-cell like a hyperlink, cleared at each line boundary so it cannot bleed onto an untagged following line. -A key validated property in all three pagers: **with the handshake absent, output -is byte-identical to the unpatched pager** — the protocol is strictly additive. +A key validated property in all three renderers: **with the handshake absent, output +is byte-identical to the unpatched renderer** — the protocol is strictly additive. --- From 063b64ba06a9f35ea46d6c33e8a36c44e8c4f223 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 5 Jul 2026 10:01:04 +0200 Subject: [PATCH 4/6] fixup! Add OSC-1717 draft spec --- diff-line-metadata-osc-spec.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/diff-line-metadata-osc-spec.md b/diff-line-metadata-osc-spec.md index 5e9041cdff4..2fbc70afad3 100644 --- a/diff-line-metadata-osc-spec.md +++ b/diff-line-metadata-osc-spec.md @@ -89,8 +89,9 @@ OSC1717_METADATA = V1[,V2,…] of protocol versions it understands, highest-preferred first is *not* required — the list is a set. - The **diff renderer** emits the **highest version present in both** its own supported - set and the advertised set. If the variable is unset, empty, or shares no - version with the renderer, the renderer **emits nothing** and its output is unchanged. + set and the advertised set (see section 4.4 for the format of the handshake + record it emits). If the variable is unset, empty, or shares no version with + the renderer, the renderer **emits nothing** and its output is unchanged. Why a handshake, and why it must exist in v1 even though v1's payload is tiny: From 7819f1429b36b61ade1e7515d3891a9874fce6c4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 16 Jul 2026 15:15:10 +0200 Subject: [PATCH 5/6] fixup! Add OSC-1717 draft spec Add `f` and `h` records --- diff-line-metadata-osc-spec.md | 216 +++++++++++++++++++++------------ 1 file changed, 139 insertions(+), 77 deletions(-) diff --git a/diff-line-metadata-osc-spec.md b/diff-line-metadata-osc-spec.md index 2fbc70afad3..ecf00450a58 100644 --- a/diff-line-metadata-osc-spec.md +++ b/diff-line-metadata-osc-spec.md @@ -133,14 +133,14 @@ Raw bytes, for a context line at new-file line 10 of `src/foo.go`: | field | presence | meaning | |---|---|---| | `version` | always | decimal protocol version; `1` for v1. Carried in every record so attachments are self-describing. | -| `type` | always | one character — see §5.1. v1 emits `c` (context), `a` (added), `d` (deleted). | -| `new-line` | always | new-file line number, in the **diff's new-file space** (see §5.2). | -| `old-line` | only `type=d` | old-file line number. **Empty** for `c` and `a`. | +| `type` | always | one character — see §5.1. v1 emits `c` (context), `a` (added), `d` (deleted), `f` (file header), `h` (hunk header). | +| `new-line` | all types except `f` | new-file line number, in the **diff's new-file space** (see §5.2). **Never present on `f`** — see §5.5. | +| `old-line` | only `type=d` | old-file line number. **Empty** for all other types. | | `file` | always | the file path the line belongs to; absolute or repo-root-relative (the host normalizes — emit whichever is convenient). Carried on **every** record so a single record is a complete answer. | For a **renamed** file, `file` is the **new** path (git's `+++ b/…` side); the old -path is not carried. A pure rename with no content change emits no records at all -(it has no content lines, only header rows). +path is not carried. A pure rename with no content change emits only its `f` +record (it has header rows but no content lines). ### 4.3 Examples @@ -151,6 +151,8 @@ path is not carried. A pure rename with no content change emits no records at al | deletion, old line 9, sits at new pos 11 | `1717;1;d;11;9;src/foo.go` | | two consecutive deletions | `…;d;11;9;…` then `…;d;11;10;…` (same `new-line`, different `old-line` — see §5.3) | | whole-file deletion | `1717;1;d;0;9;old/path` (`new-line` 0 — see §5.4) | +| file header | `1717;1;f;;;src/foo.go` (never a line number — see §5.5) | +| hunk header, hunk starting at new line 10 | `1717;1;h;10;;src/foo.go` | ### 4.4 The handshake record @@ -185,17 +187,25 @@ parse as five fields — so the handshake is harmless to existing parsers. ### 5.1 Type -`type` is one character. v1 defines three, all of them **content-line** types, and -a conforming diff renderer emits one before every content line it renders: +`type` is one character. v1 defines five. The first three are the **content-line** +types, and a conforming diff renderer emits one before every content line it +renders: - `c` — context (unchanged) line - `a` — added line - `d` — deleted line -**There is deliberately no file-header or hunk-header type — see §5.5.** The -protocol annotates content lines only; the host recovers file and hunk *structure* -from the content records themselves (the `file` field and `new-line` -discontinuities), and treats the renderer's header/decoration rows as non-actionable. +The other two are the **header** types, emitted before the structural rows: + +- `f` — file header — the row(s) a renderer prints to announce a file. Carries + **no line numbers**, ever — see §5.5. +- `h` — hunk header — the row(s) announcing a hunk (e.g. a reformatted + `@@ … @@` line). Always carries `new-line` — the hunk's first line (§5.2). + +The header types are not optional: a renderer emits `f` on each file-header row +and `h` on each hunk-header row it renders. A renderer that has no such rows +simply has none to tag, and a single row that announces both a file *and* a hunk +(difftastic's per-hunk banner) carries **both** records — see §5.5. A host **must ignore a `type` it does not recognize** (treat the row as non-actionable) rather than reject the record, so the set can grow later without a @@ -214,6 +224,11 @@ specifically on *change* lines). Hence an explicit type. expected to re-map this through its own diff↔worktree adjustment; the renderer should emit the number as it appears in the diff it is rendering. - `old-line` is the old-file line number, present **only** for deletions. +- On an `h` record, `new-line` is the new-file line of the **first line of the + hunk it heads** (for the hunk of a whole-file deletion that is `0`, matching + §5.4), and `old-line` is empty. This is what lets "open in editor" on a hunk + header land at the top of what the user is looking at. +- An `f` record carries no line numbers at all — both fields are empty (§5.5). ### 5.3 The deleted-line convention (both numbers) @@ -242,50 +257,61 @@ A host uses `old-line` to find a deletion's patch line and its old-side A deleted file's lines carry `new-line` = `0` (mirroring git's `@@ -1,N +0,0 @@`); an added file's lines carry the new-file numbers normally and `type=a`. -### 5.5 Non-goal: header and decoration rows are not annotated - -The protocol covers **content lines only**. A diff renderer's file-header and hunk-header -rows — delta's boxed file name and hunk-header box, difftastic's per-hunk banner, -diff-so-fancy's `── file ──` rule — carry **no** record, and there is no `f`/`h` -(or "file-header"/"hunk-header") type. The host treats every un-annotated row as -non-actionable. - -This is deliberate. The host does not need header records to recover diff -structure, because the structure is already implicit in the content records: - -- **File boundaries** — the `file` field changes between consecutive content - records, so the first content record carrying a new path *is* that file's entry. -- **Hunk boundaries** — `new-line` jumps by more than one between consecutive - content records of a file (lines were skipped), so a discontinuity marks a new - hunk. (Two consecutive deletions share a `new-line` by §5.3, so compute the gap - from the last *advancing* line.) - -So file/hunk navigation, "jump to the top of this file/hunk", and the rest are all -served by content records plus a trivial scan; the host lands navigation on a -hunk/file's first **content** row, backing up over any un-annotated header rows the -renderer drew above it (a few lines of host code, needed anyway — see below). - -**Why not annotate headers, even optionally?** An earlier draft made `f`/`h` -mandatory; prototyping them in delta and difftastic (preserved in the design -notes) showed the cost is real and the benefit marginal: - -- It adds genuine renderer-side friction. delta draws the file header when it parses - the `+++` line, *before* it has seen the first `@@`, so it cannot know the - header's hunk line without buffering or abandoning streaming. difftastic has no - separate header rows at all — one per-hunk banner is *both* a file and a hunk - header — so neither `f` nor `h` maps cleanly onto it. For a protocol whose whole - pitch is "emit one OSC per content line," this roughly doubles the conceptual - surface for the next diff renderer author. -- Making them *optional* is the worst of both: a host can't rely on them, so it - must implement the "header row is un-annotated → back up to the nearest content - row" fallback regardless — and then maintain two code paths forever. Dropping - the types entirely leaves the host **one** path, exercised for every diff renderer. - -The only thing genuinely lost is files that emit **no content records** at all — -pure renames, pure mode changes, binary files (§4.2). These become invisible to -the identity layer (navigation can't anchor on them). That is acceptable: they -have nothing to stage/edit/open, and remain reachable by ordinary cursor movement -over the rendered buffer. +### 5.5 Header records: `f` never carries a line number, `h` always does + +The two header types deliberately differ in payload, and the difference is fixed +by the spec rather than left to the renderer: + +- An `f` record's `new-line` and `old-line` are **always empty**. Its payload is + the file path. +- An `h` record **always** carries `new-line` — the first line of the hunk it + heads (§5.2). + +**Why fixed per type, not "emit a line number if you have one"?** Because a field +the renderer *may* populate is a field the host can never rely on — every consumer +would need a fallback for the empty case anyway, and renderers would diverge on +what they emit. Making presence a function of the type alone keeps every record's +shape predictable from its first two fields. + +**Why can't `f` require a line number?** A streaming renderer genuinely doesn't +have one at that point: delta draws its file header when it parses the `+++` +line, *before* it has seen the first `@@`, so the file's first hunk line is +unknown unless it buffered the header — abandoning its streaming design for one +field. And a file header doesn't need a line number: the actions it anchors +(open the file, jump to the file, list the files) are file-granular. A host that +wants a line anyway can scan forward to the file's first record that carries one. + +**Why can `h` require one?** Every renderer knows a hunk's start line at the +moment it renders that hunk's header — it is right there in the `@@` line being +reformatted, or (for a renderer like difftastic that builds the whole diff before +rendering) in the hunk structure itself. + +**Combined file+hunk headers emit both records.** Some renderers have no separate +file-header row: difftastic prints one banner per hunk (`path --- N/M --- lang`), +and the first hunk's banner is the only row announcing the file. The rule: a row +that announces both a file and a hunk carries **both** records — the `f` first, +then the `h` (the file is the outer structure). difftastic's first-hunk banner +thus carries an `f` and an `h`; its later banners carry only an `h`. A consequence +is that a row can carry several records even outside side-by-side mode, and not +all of them carry a line number — §7 lists what a consuming host should be aware +of. + +Two useful invariants follow from all of this: + +- every file in the diff has exactly one `f` (a multi-row header repeats it per + row — §6.4 — but it is one logical record), and every hunk exactly one `h`, so + file/hunk navigation and a "files in this diff" list fall directly out of the + records; +- files with **no content lines** — pure renames, mode-only changes, binary files + (§4.2) — still emit their `f`, so they stay visible to the identity layer: + navigation can anchor on them and a file list includes them, even though there + is no content to act on. + +(Hosts that consume only content records can still recover the structure without +headers: the `file` field changes between consecutive content records at a file +boundary, and within a file a `new-line` jump of more than one marks a new hunk — +two consecutive deletions share a `new-line` by §5.3, so compute the gap from the +last *advancing* line. This remains valid, but it cannot see content-less files.) --- @@ -305,6 +331,9 @@ call, not the host's. The single firm requirement: > **The record must precede the region's first cell**, so that a host searching > leftward from any cell lands in the correct region. +A combined file+hunk header row (§5.5, §6.4) is the one case where *two* records +precede the same region — both before its first cell, `f` then `h`. + ### 6.2 Multiple regions per row (side-by-side) A side-by-side row shows two source lines at once, so it carries **two records**, @@ -350,12 +379,24 @@ Getting this wrong is not theoretical: without per-row records, acting on a wrapped continuation row does nothing, and hunk/file navigation breaks because the untagged rows fragment a wrapped line into one block per visual row. -### 6.4 Header and decoration rows — emit nothing +### 6.4 Header rows + +File-header and hunk-header rows carry records too, and these are **mandatory**: +`f` on each file-header row, `h` on each hunk-header row (§5.1, §5.5). A row that +announces both a file and a hunk carries both, `f` before `h`. -A diff renderer's header and decoration rows (file headers, hunk headers, dividers, -padding) carry **no** record — there is no header type to emit (§5.1, §5.5). The -host derives file and hunk structure from the content records and treats every -un-annotated row as non-actionable. +Where a header spans **several rows** — delta boxes a file name in divider/name/ +divider lines; a renderer might draw a rule under a hunk header — **every row of +that block carries the same record(s)**: all three box rows get the file's `f`, a +hunk header and its rule both get that hunk's `h`, exactly as a wrapped content +line re-emits its record on every row (§6.3). That leaves no dead rows in a +header block — the user can act anywhere on it and land in the same place. The +renderer has the final say over what counts as its header block and which rows it +tags; this is the recommended default, not a hard rule. + +Rows that belong to no header — dividers between files, padding, other pure +decoration — carry no record, and the host treats every un-annotated row as +non-actionable. --- @@ -367,13 +408,31 @@ is designed for, to make the emit rules concrete. - The host attaches each record to the following cell, like an OSC-8 hyperlink. If a record has no following cell (a genuinely empty rendered line), the host adds a content-less cell to hold it. -- **Row-granular action** (e.g. a keyboard "act on this line"): use the **first** - record on the row. In side-by-side this is the left column — fine, since the two - sides of a change are one hunk for staging purposes. - **Point-granular action** (a mouse click): the per-cell attachment lets a host use the **nearest record at or to the left of the click column**, landing in the column actually clicked. (A host may equally resolve clicks at row granularity; the carrier supports either.) +- **Row-granular action** (e.g. a keyboard "act on this line"): a row can carry + **more than one** record, so the host picks. The spec doesn't prescribe which — + the right choice depends on the action — but these are the situations to be + aware of: + - A **side-by-side** row carries one record per column (§6.2). The two records + are one logical change, but their fields differ: the left `d` records of a + change block all sit at the block's first new line (§5.3), so the right + column's `a` record has the more precise `new-line`, while the left one is + the only carrier of the old-side identity. + - A **combined file+hunk header** carries an `f` and an `h` (§5.5). Only the + `h` has a line number, so a line-oriented action (open the editor at a line) + wants the `h`, while a file-oriented one (open the file, build a file list) + anchors on the `f`. + - When several records precede the same cell (the combined header), a + click resolved by "nearest at or left of the point" lands on the **last** + record emitted before that cell — the `h`. A host that wants keyboard and + mouse actions to agree on such rows should account for that. +- An `f` record has no line number. What an action does with that is the host's + choice — e.g. "open in editor" can open the file without jumping anywhere + (useful in itself), or scan forward for the file's first record that carries a + line number. - The host normalizes `file` (resolving it relative to the repository working tree) and otherwise treats the record as opaque identity. @@ -418,15 +477,15 @@ mapping; recorded as a v2 candidate, not taken (§9). the side for deleted lines, and in side-by-side mode. (delta needed to track its own old/new counters because its line-number counters are dormant unless `--line-numbers` is on; difftastic had them natively. Your mileage may vary.) -5. **Content-lines-only scope (§5.5) — is anything lost for your diff renderer?** We - deliberately dropped header annotations: an earlier draft made file/hunk-header - types mandatory, but prototyping them in delta and difftastic showed real - renderer-side friction (delta draws the file header before it has parsed the first - `@@`; difftastic has no separate header rows, only a combined per-hunk banner) - for benefit the host can get by deriving structure from content records. If your - renderer has a structure where the host genuinely *cannot* reconstruct file/hunk - boundaries from content records, tell us — that would argue for bringing header - types back. +5. **The header types' fixed payloads (§5.5).** `f` never carries a line number, + `h` always does, and a combined file+hunk row emits both records. This shape + came out of prototyping in delta and difftastic: delta streams and draws its + file header before it has parsed the first `@@` (so `f` cannot promise a + line), while every renderer knows a hunk's start line at its hunk header (so + `h` can); difftastic's per-hunk banner is both headers at once (so combined + rows emit both). Does this fit your renderer — do you have a header shape + where `h`'s line number is *not* in hand, or a combined row the both-records + rule doesn't cover? --- @@ -438,19 +497,22 @@ consume the v1 format described here, over OSC `1717`: - **delta** — a dedicated additive emitter that injects only OSC bytes (no change to styling, width, or wrapping); with the env var unset, output is byte-for-byte identical to stock delta. Covers unified and side-by-side modes, including - wrapped rows. (A `f`/`h` header-record variant was also prototyped before headers - were dropped from the spec — see §5.5 / the design notes — but is not part of - this content-line-only protocol.) + wrapped rows, and the multi-row file/hunk-header decorations (every row of a + header block carries its `f`/`h` — §6.4; delta's `f` is the case that cannot + carry a line number, §5.5). - **difftastic** — the categorical case (#1 host-side parsing cannot serve it in either mode). Emits the same v1 format under the same handshake; markedly less code than delta because difftastic carries old/new line numbers natively. Covers - side-by-side and inline modes. + side-by-side and inline modes. Its per-hunk banner is the combined file+hunk + header of §5.5: the first hunk's banner carries `f` and `h`, later banners `h`. - **diff-so-fancy** — the same #2 case as delta's default (it strips the `+`/`-` markers and conveys the side by color), but a line-oriented Perl filter rather than a structured renderer, and the simplest of the three: unified single-column only (no side-by-side). Classifies each line by its leading `+`/`-` before its existing code strips that marker, tracking its own old/new counters seeded from - each `@@` header (like delta, it has no native line numbers). Combined/merge + each `@@` header (like delta, it has no native line numbers); its reformatted + file rule carries `f` and its hunk-header line `h` (the `@@` line it reformats + has the hunk's start in hand). Combined/merge diffs are not annotated. Because diff-so-fancy defensively strips terminal escape sequences from the content it renders, the record is *prepended* to the line rather than embedded in it. From 538cea8f006890c87c4b38f107610112bdbbcb4b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 18 Jul 2026 17:14:14 +0200 Subject: [PATCH 6/6] fixup! Add OSC-1717 draft spec --- diff-line-metadata-osc-spec.md | 121 +++++++++++++++++++++++++-------- 1 file changed, 91 insertions(+), 30 deletions(-) diff --git a/diff-line-metadata-osc-spec.md b/diff-line-metadata-osc-spec.md index ecf00450a58..a11c1c3b129 100644 --- a/diff-line-metadata-osc-spec.md +++ b/diff-line-metadata-osc-spec.md @@ -150,6 +150,7 @@ record (it has header rows but no content lines). | addition, new line 11 | `1717;1;a;11;;src/foo.go` | | deletion, old line 9, sits at new pos 11 | `1717;1;d;11;9;src/foo.go` | | two consecutive deletions | `…;d;11;9;…` then `…;d;11;10;…` (same `new-line`, different `old-line` — see §5.3) | +| modification rendered as one row | `…;d;11;9;…` then `…;a;11;;…` back-to-back (both halves of the row — see §6.2) | | whole-file deletion | `1717;1;d;0;9;old/path` (`new-line` 0 — see §5.4) | | file header | `1717;1;f;;;src/foo.go` (never a line number — see §5.5) | | hunk header, hunk starting at new line 10 | `1717;1;h;10;;src/foo.go` | @@ -195,6 +196,16 @@ renders: - `a` — added line - `d` — deleted line +These are **patch-space** types, and the renderer classifies in patch space — +not by its own display model. Concretely: `c` only for a line whose old and new +contents are byte-identical; a pair of aligned lines whose contents differ is a +`d` plus an `a`, and a line present on only one side is a `d` or `a` — however +the renderer paints them. This matters for renderers finer-grained than git's +line model (difftastic marks novelty per *token*, so a line changed only by +added tokens has nothing highlighted on its old side, and a blank line has no +tokens at all) — classifying by highlighting would tag such lines `c`, and a +host staging by the records would drop one half of the modification. See §8. + The other two are the **header** types, emitted before the structural rows: - `f` — file header — the row(s) a renderer prints to announce a file. Carries @@ -331,8 +342,12 @@ call, not the host's. The single firm requirement: > **The record must precede the region's first cell**, so that a host searching > leftward from any cell lands in the correct region. -A combined file+hunk header row (§5.5, §6.4) is the one case where *two* records -precede the same region — both before its first cell, `f` then `h`. +A region can be **zero-width**: several records may precede the same cells, each +immediately following the last. This happens on a combined file+hunk header row +(§5.5, §6.4 — `f` then `h` before its first cell) and on a modification rendered +as a single row (§6.2 — `d` then `a`). A host must keep *every* record of a row, +not just the one nearest its cells — a record followed directly by another +record still names one of the row's identities (see §7). ### 6.2 Multiple regions per row (side-by-side) @@ -353,6 +368,16 @@ context line's two halves are the *same* logical line, so the host tells the two its keep to disambiguate the symmetric `c` case, and that case needs no disambiguation. +**A modification rendered as a single row carries both records.** A renderer may +show an aligned changed row as *one* column — difftastic collapses a hunk whose +changes are all one-sided, printing a modified row's content once. The row still +*represents* both patch lines, so it carries the pair anyway: the `d`, then the +`a`, emitted consecutively at the row's start (patch order — deletions first; the +`d`'s region is zero-width, §6.1). This is what lets a host act on the whole +change from the one row it can see — staging it stages both halves, exactly as +for the two-column rendering of the same row. A collapsed *context* row is one +logical line and carries its `c` **once**, not per absent column. + ### 6.3 Wrapping — emit on every output row > **The diff renderer emits a line's record at the start of *every output row* it produces @@ -406,8 +431,10 @@ This section is not normative — it sketches the access model the per-cell carr is designed for, to make the emit rules concrete. - The host attaches each record to the following cell, like an OSC-8 hyperlink. If - a record has no following cell (a genuinely empty rendered line), the host adds - a content-less cell to hold it. + a record has no following cell of its own — a genuinely empty rendered line, or + a zero-width region whose record is immediately followed by the next record + (§6.1) — the host adds a content-less cell to hold it, so no record of a row is + lost to the one after it. - **Point-granular action** (a mouse click): the per-cell attachment lets a host use the **nearest record at or to the left of the click column**, landing in the column actually clicked. (A host may equally resolve clicks at row granularity; @@ -421,6 +448,10 @@ is designed for, to make the emit rules concrete. change block all sit at the block's first new line (§5.3), so the right column's `a` record has the more precise `new-line`, while the left one is the only carrier of the old-side identity. + - A **modification collapsed to a single row** (§6.2) carries the same `d`+`a` + pair, just back-to-back at the row's start. A host that acts on every record + of a row (the natural choice for staging) handles the two renderings + identically without telling them apart. - A **combined file+hunk header** carries an `f` and an `h` (§5.5). Only the `h` has a line number, so a line-oriented action (open the editor at a line) wants the `h`, while a file-oriented one (open the file, build a file list) @@ -438,27 +469,49 @@ is designed for, to make the emit rules concrete. --- -## 8. A known limitation and v2 candidate — feedback wanted - -This doesn't block v1; it's a place where input would shape a future version. - -**The token-vs-line model mismatch (difftastic, AST mode).** Our `c`/`a`/`d` set -is git's **line-granular** shape: a modified line is a `-` plus -a `+`. difftastic, when it parses the language (its **AST/token mode**), is finer — -it aligns lines and marks novelty per token. A line changed *only by added tokens* -(e.g. `println!("{}", x);` → `println!("{}", x + y);`) then has **no novelty on the -old side**, so difftastic renders that old line as context (dimmed, not red) and the -record faithfully says `c`, not `d`; only the new line gets `a`. (In difftastic's -line/Text fallback — e.g. an unparseable file — it diffs by line and emits `d`/`a` -as expected.) - -Because the record matches what difftastic shows, this isn't visible within -difftastic itself. The consequence is only for a host mapping cells back to git's -*line* diff: that old cell reports context at the new line, so its old-side `-` -identity isn't recoverable (impact small — `e` opens the right new-file line, and -users act on the changed side). A `modified`/`m` type — "aligned, changed, present -on both sides" — would name the case directly but splits the clean `c`/`a`/`d` -mapping; recorded as a v2 candidate, not taken (§9). +## 8. The token-vs-line model mismatch — how v1 resolves it, and the residue + +**The mismatch (difftastic, AST mode).** Our `c`/`a`/`d` set is git's +**line-granular** shape: a modified line is a `-` plus a `+`. difftastic, when it +parses the language (its **AST/token mode**), is finer — it aligns lines and +marks novelty per token. A line changed *only by added tokens* (e.g. +`println!("{}", x);` → `println!("{}", x + y);`) then has **no novelty on the old +side** — difftastic renders that old line with nothing highlighted (dimmed like +context, or not at all when it collapses the hunk to a single column) — and a +blank line has no tokens to be novel in the first place. (In difftastic's +line/Text fallback — e.g. an unparseable file — it diffs by line and the mismatch +doesn't arise.) + +**The resolution: classify in patch space (§5.1).** An early draft let the +record follow the display's highlighting — the old line of such a modification +said `c`. That reads consistently *within* difftastic, but it hands the host a +lie in patch space, and the lie is load-bearing: a host staging by the records +then staged only the `a` half of the modification, inserting the new line while +keeping the old (or, mirrored — a line changed only by *removed* tokens — only +the `d` half, deleting the line without its replacement). So v1 requires the +renderer to compare the aligned lines' *contents*: a differing pair is a `d` +plus an `a` whatever the display highlights, and a row that is the only rendered +representative of both halves carries both records (§6.2). Genuinely identical +lines inside a hunk still compare equal and stay `c`. + +**The residue: changes the renderer never renders.** The records can only +describe rows that exist: + +- difftastic treats whitespace-only line changes as no change at all — a file + whose only edit is reindentation, or a lone deleted blank line, reports "no + syntactic changes" and renders nothing — so there is no row to carry a record. +- an aligned pair differing only in whitespace that falls in a *context region* + of difftastic's inline mode is rendered on one side only (before-context shows + the old file's text, after-context the new file's); its record stays `c`, + because a one-sided `d` would hand the host a deletion whose `a` half no + rendered row can ever carry. + +Both are inherent to a renderer whose diff model is coarser than git's in the +whitespace dimension; a host that needs those changes falls back on the raw +diff. A `modified`/`m` type — "aligned, changed, present on both sides" — remains +a v2 candidate (§9): it would name a single-row modification in one record +rather than a back-to-back pair, but splits the clean `c`/`a`/`d` mapping, and +the pair already carries both identities. --- @@ -471,8 +524,10 @@ mapping; recorded as a v2 candidate, not taken (§9). central registry, so this is "verified unused across the terminals that matter," not "allocated." If you know of a terminal that interprets `1717`, please say so. 2. **The env-var name and grammar** (`OSC1717_METADATA=V1,…`). -3. **The token-vs-line mismatch** (§8) — should there be an `m` type, or is - host-side inference the right home for it? +3. **The token-vs-line mismatch** (§8) — v1 resolves it by classifying in patch + space (a content-differing aligned pair is `d`+`a`, emitted together when only + one row renders it). Is the back-to-back pair right, or should a v2 `m` type + name the single-row modification directly? 4. **Can your diff renderer actually produce all four fields per region?** In particular the side for deleted lines, and in side-by-side mode. (delta needed to track its own old/new counters because its line-number counters are dormant unless @@ -503,8 +558,11 @@ consume the v1 format described here, over OSC `1717`: - **difftastic** — the categorical case (#1 host-side parsing cannot serve it in either mode). Emits the same v1 format under the same handshake; markedly less code than delta because difftastic carries old/new line numbers natively. Covers - side-by-side and inline modes. Its per-hunk banner is the combined file+hunk - header of §5.5: the first hunk's banner carries `f` and `h`, later banners `h`. + side-by-side and inline modes, classifying in patch space by comparing the + aligned lines' contents (§5.1/§8) — its collapsed single-column rows are where + the back-to-back `d`+`a` of §6.2 comes from. Its per-hunk banner is the combined + file+hunk header of §5.5: the first hunk's banner carries `f` and `h`, later + banners `h`. - **diff-so-fancy** — the same #2 case as delta's default (it strips the `+`/`-` markers and conveys the side by color), but a line-oriented Perl filter rather than a structured renderer, and the simplest of the three: unified single-column @@ -518,7 +576,10 @@ consume the v1 format described here, over OSC `1717`: rather than embedded in it. - **host carrier** — gocui (lazygit's TUI library) accumulates the OSC number, collects the payload, and stamps it per-cell like a hyperlink, cleared at each - line boundary so it cannot bleed onto an untagged following line. + line boundary so it cannot bleed onto an untagged following line. A record + that no cell consumed — a zero-width region (§6.1), or a record right before + the line end — is kept in a content-less carrier cell, so a row's records + survive complete. A key validated property in all three renderers: **with the handshake absent, output is byte-identical to the unpatched renderer** — the protocol is strictly additive.