From 00df5177e5fd646823a6be439651d6942e0ee708 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 7 Jun 2026 17:44:07 +0200 Subject: [PATCH] gocui: parse OSC 456 per-line diff metadata and attach it per cell A patched pager (delta) prefixes each diff line with an OSC 456 sequence carrying that line's patch-space identity (see diff-line-metadata-notes.md), so the host can map a rendered row back to (file, type, new-line, old-line) without re-parsing -- the only way to recover the side for renderings that drop the +/- markers (delta's default mode). Recognize it in the escape interpreter and stamp the payload onto each cell, mirroring how OSC-8 hyperlinks are handled, exposing it via DiffLineMetadataInLine. To do so, generalize the OSC dispatch to accumulate the (possibly multi-digit) OSC number before branching, rather than matching the single character '8'; the OSC-8 path is unchanged for well-formed input. Unlike a hyperlink, the metadata sequence is never closed -- the pager re-emits one per line -- so clear it at each line boundary to keep it from bleeding onto a following line that has none (e.g. a hunk header). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/escape.go | 69 ++++++++++++++++++++++++++++++------------ pkg/gocui/view.go | 43 +++++++++++++++++++++++++- pkg/gocui/view_test.go | 37 ++++++++++++++++++++++ 3 files changed, 128 insertions(+), 21 deletions(-) diff --git a/pkg/gocui/escape.go b/pkg/gocui/escape.go index 7f3de9e6e..b19bea235 100644 --- a/pkg/gocui/escape.go +++ b/pkg/gocui/escape.go @@ -20,6 +20,12 @@ type escapeInterpreter struct { instruction instruction hyperlink strings.Builder + // the OSC number being accumulated while we don't yet know which OSC this is + oscNumber strings.Builder + // the payload of an OSC 456 per-line diff-metadata sequence (see + // diff-line-metadata-notes.md), accumulated like hyperlink + metadata strings.Builder + // ConPTY emits cursor-positioning escapes (CUP) to skip over blank // rows rather than emitting LFs for them. To convert those into row // advances the view can act on, we track where in the pseudo-terminal @@ -82,9 +88,9 @@ const ( stateParams stateCSIDiscard stateOSC - stateOSCWaitForParams stateOSCParams stateOSCHyperlink + stateOSCMetadata stateOSCEndEscape stateOSCSkipUnknown @@ -427,27 +433,40 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { } return true, nil case stateOSC: - if characterEquals(ch, '8') { - ei.state = stateOSCWaitForParams - ei.hyperlink.Reset() + // Accumulate the OSC number until its terminating ';', then dispatch on + // it. (The previous code only recognised the single-digit '8'; a number + // like 456 needs more than one character.) + switch { + case len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9': + ei.oscNumber.WriteByte(ch[0]) + return true, nil + case characterEquals(ch, ';'): + switch ei.oscNumber.String() { + case "8": + ei.hyperlink.Reset() + ei.state = stateOSCParams + case "456": + ei.metadata.Reset() + ei.state = stateOSCMetadata + default: + ei.state = stateOSCSkipUnknown + } + ei.oscNumber.Reset() + return true, nil + default: + // Not a recognized OSC; skip to its terminator (handling the case + // where this character already is one). + ei.oscNumber.Reset() + switch { + case characterEquals(ch, 0x07): + ei.state = stateNone + case characterEquals(ch, 0x1b): + ei.state = stateOSCEndEscape + default: + ei.state = stateOSCSkipUnknown + } return true, nil } - - ei.state = stateOSCSkipUnknown - return true, nil - case stateOSCWaitForParams: - if !characterEquals(ch, ';') { - // Malformed OSC 8 (expected ';' after '8'). Rather than - // erroring — which would reset state mid-OSC and cause the - // rest of the sequence to leak as literal text — treat the - // whole OSC as one we don't understand and skip to its - // terminator. - ei.state = stateOSCSkipUnknown - return true, nil - } - - ei.state = stateOSCParams - return true, nil case stateOSCParams: if characterEquals(ch, ';') { ei.state = stateOSCHyperlink @@ -463,6 +482,16 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { ei.hyperlink.Write(ch) } return true, nil + case stateOSCMetadata: + switch { + case characterEquals(ch, 0x07): + ei.state = stateNone + case characterEquals(ch, 0x1b): + ei.state = stateOSCEndEscape + default: + ei.metadata.Write(ch) + } + return true, nil case stateOSCEndEscape: ei.state = stateNone return true, nil diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index dfbbfd0c8..1e3a584bc 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -502,6 +502,9 @@ type cell struct { width int // number of terminal cells occupied by chr (always 1 or 2) bgColor, fgColor Attribute hyperlink string + // per-line diff metadata from an OSC 456 sequence (see + // diff-line-metadata-notes.md); empty unless a pager emitted it + metadata string } type cells []cell @@ -866,6 +869,10 @@ func (v *View) write(p []byte) { if v.wy >= len(v.lines) { v.lines = append(v.lines, lineType{}) } + // An OSC 456 diff-metadata sequence applies only to the line it prefixes + // (the pager re-emits one per line and never closes it), so drop it at the + // line boundary rather than letting it carry onto a line with no metadata. + v.ei.metadata.Reset() } if v.pendingNewline { @@ -1064,6 +1071,7 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) { fgColor: v.ei.curFgColor, bgColor: v.ei.curBgColor, hyperlink: v.ei.hyperlink.String(), + metadata: v.ei.metadata.String(), chr: string(ch), width: width, } @@ -1666,7 +1674,7 @@ func (v *View) HyperLinkInLine(y int, urlScheme string) (string, bool) { return "", false } - for _, c := range v.lines[linesY].cells { + for _, c := range v.lines[linesY].cells { if strings.HasPrefix(c.hyperlink, urlScheme) { return c.hyperlink, true } @@ -1675,6 +1683,39 @@ func (v *View) HyperLinkInLine(y int, urlScheme string) (string, bool) { return "", false } +// DiffLineMetadataInLine returns the OSC 456 per-line diff metadata payload +// attached to the given (wrapped) view line, if a pager emitted one. In the +// single-column case every cell of the line carries the same payload, so the +// first non-empty one is the answer. See diff-line-metadata-notes.md. +func (v *View) DiffLineMetadataInLine(y int) (string, bool) { + // Take the lock so we don't race a concurrent re-render that is rebuilding the + // buffer. + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + v.refreshViewLinesIfNeeded() + + if y < 0 || y >= len(v.viewLines) { + return "", false + } + + // refreshViewLinesIfNeeded overwrites viewLines in place without truncating, + // so while a shorter re-render is loading, the tail of viewLines can still + // hold stale entries pointing past the (shrunk) v.lines. Guard against that. + linesY := v.viewLines[y].linesY + if linesY >= len(v.lines) { + return "", false + } + + for _, c := range v.lines[linesY].cells { + if c.metadata != "" { + return c.metadata, true + } + } + + return "", false +} + // BufferLineForViewLine maps a view line index (which counts wrapped lines) to // the index of the corresponding line in the unwrapped internal buffer (as // returned by BufferLines). Several view lines can map to the same buffer line diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index f7e229f1c..e6e24c8b5 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -159,6 +159,43 @@ func TestAutoRenderingHyperlinks(t *testing.T) { assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink) } +func TestDiffLineMetadata(t *testing.T) { + v := NewView("name", 0, 0, 80, 10, OutputNormal) + + // Synthetic delta-style output: each content line is prefixed with an + // OSC 456 sequence carrying version;type;new;old;file (old empty unless a + // deletion), and the OSC bytes themselves must not become visible cells. The + // final line is a header with no OSC, to prove the metadata doesn't bleed. + osc := func(payload string) string { return "\x1b]456;" + payload + "\x1b\\" } + v.writeString(strings.Join([]string{ + osc("1;c;1;;foo.txt") + "line1", + osc("1;d;2;2;foo.txt") + "old2", + osc("1;a;2;;foo.txt") + "new2", + "@@ a header line with no metadata @@", + }, "\n")) + + type result struct { + payload string + ok bool + } + got := make([]result, len(v.lines)) + for y := range v.lines { + payload, ok := v.DiffLineMetadataInLine(y) + got[y] = result{payload, ok} + } + + assert.Equal(t, []result{ + {"1;c;1;;foo.txt", true}, + {"1;d;2;2;foo.txt", true}, + {"1;a;2;;foo.txt", true}, + {"", false}, // the header line carries no metadata (no bleed) + }, got) + + // The OSC sequence is consumed as an escape, so the visible text is intact. + assert.Equal(t, "line1", v.BufferLines()[0]) + assert.Equal(t, "@@ a header line with no metadata @@", v.BufferLines()[3]) +} + func TestContainsColoredText(t *testing.T) { hexColor := func(text string, hexStr string) []cell { cells := make([]cell, len(text))