server: VT emulator framebuffer diffing

Replace raw PTY passthrough with proper VT emulator processing.
PTY output feeds into charmbracelet/x/vt, snapshots are diffed
against the last-acked framebuffer using CUP-based positioning.
Fixes character doubling during fast typing.
This commit is contained in:
realrasengan
2026-03-14 20:34:30 -07:00
parent bb08f71e30
commit b8b5994527
6 changed files with 296 additions and 44 deletions
+80
View File
@@ -1,7 +1,13 @@
package mosh
import (
"image/color"
"strconv"
"unicode/utf8"
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/vt"
)
// Framebuffer represents a terminal screen state for mosh SSP.
@@ -361,3 +367,77 @@ func encodeRune(buf []byte, r rune) int {
return 4
}
}
// SnapshotEmulator captures the VT emulator state into a Framebuffer.
func SnapshotEmulator(emu *vt.Emulator, cursorVisible bool) *Framebuffer {
w := emu.Width()
h := emu.Height()
fb := NewFramebuffer(w, h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
c := emu.CellAt(x, y)
if c == nil {
continue
}
cell := &fb.Cells[y*w+x]
// Content → first rune.
if c.Content == "" || c.Content == " " {
cell.Rune = ' '
} else {
r, _ := utf8.DecodeRuneInString(c.Content)
if r == utf8.RuneError {
cell.Rune = ' '
} else {
cell.Rune = r
}
}
cell.Width = c.Width
if cell.Width == 0 && cell.Rune == ' ' {
// Continuation cell.
cell.Width = 0
}
// Attributes.
cell.Attr.Bold = c.Style.Attrs&uv.AttrBold != 0
cell.Attr.Dim = c.Style.Attrs&uv.AttrFaint != 0
cell.Attr.Italic = c.Style.Attrs&uv.AttrItalic != 0
cell.Attr.Blink = c.Style.Attrs&uv.AttrBlink != 0
cell.Attr.Reverse = c.Style.Attrs&uv.AttrReverse != 0
cell.Attr.Strike = c.Style.Attrs&uv.AttrStrikethrough != 0
cell.Attr.Under = c.Style.Underline != uv.UnderlineNone
cell.Attr.FG = convertColor(c.Style.Fg)
cell.Attr.BG = convertColor(c.Style.Bg)
}
}
pos := emu.CursorPosition()
fb.CurX = pos.X
fb.CurY = pos.Y
fb.CurVis = cursorVisible
return fb
}
// convertColor converts a color.Color from the VT emulator to our Color type.
func convertColor(c color.Color) Color {
if c == nil {
return Color{Type: ColorDefault}
}
switch v := c.(type) {
case ansi.BasicColor:
return Color{Type: ColorIndex, Value: uint32(v)}
case ansi.IndexedColor:
return Color{Type: ColorIndex, Value: uint32(v)}
case ansi.TrueColor:
return Color{Type: ColorRGB, Value: uint32(v)}
case color.RGBA:
return Color{Type: ColorRGB, Value: uint32(v.R)<<16 | uint32(v.G)<<8 | uint32(v.B)}
default:
r, g, b, _ := c.RGBA()
return Color{Type: ColorRGB, Value: uint32(r>>8)<<16 | uint32(g>>8)<<8 | uint32(b>>8)}
}
}