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) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-06-07 17:44:07 +02:00
parent 0e35ebbb55
commit 00df5177e5
3 changed files with 128 additions and 21 deletions

View file

@ -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

View file

@ -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

View file

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