add local echo prediction engine, integrate into WASM client
This commit is contained in:
@@ -95,6 +95,16 @@ Standard mosh clients and servers ignore them (forward-compatible).
|
||||
- **Session control** (Host/UserInstruction field 9): list, switch,
|
||||
and create latch sessions over the mosh connection.
|
||||
|
||||
## WASM build
|
||||
|
||||
The mosh client compiles to WebAssembly for use in the web terminal:
|
||||
|
||||
```
|
||||
GOOS=js GOARCH=wasm CGO_ENABLED=0 go build -tags osusergo -o mosh.wasm ./cmd/mosh-wasm/
|
||||
```
|
||||
|
||||
The `-tags osusergo` flag is required to avoid `os/user` CGO issues with `GOOS=js`.
|
||||
|
||||
## Files
|
||||
|
||||
- `server.go` -- server, PTY lifecycle, `ServeRW` bridge mode
|
||||
@@ -104,6 +114,7 @@ Standard mosh clients and servers ignore them (forward-compatible).
|
||||
- `fragment.go` -- datagram fragmentation and reassembly
|
||||
- `pb.go` -- protobuf encoding (transport, host, user, latch extensions)
|
||||
- `framebuffer.go` -- terminal state tracking for diff protocol
|
||||
- `predict.go` -- local echo prediction engine (speculative keystroke rendering)
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
||||
@@ -98,9 +98,15 @@ func (s *session) jsObject() js.Value {
|
||||
if len(args) < 1 {
|
||||
return nil
|
||||
}
|
||||
s.client.Send([]byte(args[0].String()))
|
||||
data := []byte(args[0].String())
|
||||
s.client.Send(data)
|
||||
s.client.Tick()
|
||||
return nil
|
||||
|
||||
// Feed input to predictor — returns immediate overlay output.
|
||||
if out := s.tracker.keystroke(data); len(out) > 0 {
|
||||
return string(out)
|
||||
}
|
||||
return js.Null()
|
||||
}))
|
||||
|
||||
obj.Set("resize", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
|
||||
+71
-7
@@ -4,6 +4,7 @@ package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mosh "github.com/unixshells/mosh-go"
|
||||
vt "github.com/unixshells/vt-go"
|
||||
@@ -27,16 +28,20 @@ type stateTracker struct {
|
||||
latestState uint64
|
||||
displayedFB *mosh.Framebuffer
|
||||
|
||||
// Prediction engine for local echo.
|
||||
predictor *mosh.Predictor
|
||||
|
||||
// Output buffer: pre-diffed ANSI ready for the terminal.
|
||||
output []byte
|
||||
}
|
||||
|
||||
func newStateTracker(cols, rows int) *stateTracker {
|
||||
return &stateTracker{
|
||||
shadow: vt.NewEmulator(cols, rows),
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
states: make(map[uint64]*mosh.Framebuffer),
|
||||
shadow: vt.NewEmulator(cols, rows),
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
states: make(map[uint64]*mosh.Framebuffer),
|
||||
predictor: mosh.NewPredictor(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,14 +107,24 @@ func (st *stateTracker) applyDiff(diff []byte, oldNum, newNum, throwawayNum uint
|
||||
}
|
||||
}
|
||||
|
||||
// Display diff: compute ANSI difference between displayed and latest.
|
||||
// Confirm predictions against server state.
|
||||
latest := st.states[st.latestState]
|
||||
if latest != nil {
|
||||
ansi := latest.Diff(st.displayedFB)
|
||||
st.predictor.Confirm(latest)
|
||||
st.predictor.ExpireStale(time.Now())
|
||||
|
||||
// Apply prediction overlay for display.
|
||||
displayFB := latest
|
||||
if st.predictor.Active() {
|
||||
displayFB = st.cloneFB(latest)
|
||||
st.predictor.Overlay(displayFB)
|
||||
}
|
||||
|
||||
ansi := displayFB.Diff(st.displayedFB)
|
||||
if len(ansi) > 0 {
|
||||
st.output = append(st.output, ansi...)
|
||||
}
|
||||
st.displayedFB = latest
|
||||
st.displayedFB = displayFB
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +140,54 @@ func (st *stateTracker) poll() []byte {
|
||||
return out
|
||||
}
|
||||
|
||||
// keystroke feeds user input to the predictor and returns any prediction
|
||||
// overlay output that should be displayed immediately.
|
||||
func (st *stateTracker) keystroke(data []byte) []byte {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
|
||||
st.predictor.Keystroke(data)
|
||||
|
||||
if !st.predictor.Active() || st.displayedFB == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Overlay predictions onto the current display and diff.
|
||||
overlaid := st.cloneFB(st.displayedFB)
|
||||
|
||||
// First undo previous prediction overlay by using latest server state.
|
||||
var serverFB *mosh.Framebuffer
|
||||
if st.latestState > 0 {
|
||||
serverFB = st.states[st.latestState]
|
||||
}
|
||||
if serverFB == nil {
|
||||
serverFB = st.displayedFB
|
||||
}
|
||||
|
||||
displayFB := st.cloneFB(serverFB)
|
||||
st.predictor.Overlay(displayFB)
|
||||
|
||||
ansi := displayFB.Diff(overlaid)
|
||||
if len(ansi) > 0 {
|
||||
st.displayedFB = displayFB
|
||||
}
|
||||
return ansi
|
||||
}
|
||||
|
||||
// cloneFB creates a deep copy of a framebuffer.
|
||||
func (st *stateTracker) cloneFB(fb *mosh.Framebuffer) *mosh.Framebuffer {
|
||||
clone := &mosh.Framebuffer{
|
||||
W: fb.W,
|
||||
H: fb.H,
|
||||
CurX: fb.CurX,
|
||||
CurY: fb.CurY,
|
||||
CurVis: fb.CurVis,
|
||||
Cells: make([]mosh.Cell, len(fb.Cells)),
|
||||
}
|
||||
copy(clone.Cells, fb.Cells)
|
||||
return clone
|
||||
}
|
||||
|
||||
// resize resets the state tracker for new dimensions.
|
||||
func (st *stateTracker) resize(cols, rows int) {
|
||||
st.mu.Lock()
|
||||
@@ -136,6 +199,7 @@ func (st *stateTracker) resize(cols, rows int) {
|
||||
st.shadowState = 0
|
||||
st.states = make(map[uint64]*mosh.Framebuffer)
|
||||
st.displayedFB = nil
|
||||
st.predictor.Reset()
|
||||
}
|
||||
|
||||
// resetShadow clears the shadow emulator without reallocating it.
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
package mosh
|
||||
|
||||
import (
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Predictor implements mosh-style speculative local echo.
|
||||
//
|
||||
// When the user types a printable character, it is predicted to appear
|
||||
// at the current cursor position and the cursor advances. When the
|
||||
// server confirms the character (it appears in the server's framebuffer
|
||||
// at the expected position), the prediction is retired. If the server
|
||||
// diverges, all predictions are cleared.
|
||||
//
|
||||
// This is the core of what makes mosh feel local: keystrokes appear
|
||||
// instantly even over high-latency connections.
|
||||
type Predictor struct {
|
||||
pending []prediction // unconfirmed predictions, in order typed
|
||||
curX int // predicted cursor X (columns)
|
||||
curY int // predicted cursor Y (rows)
|
||||
epoch int // incremented on reset; stale predictions are ignored
|
||||
active bool // false until first prediction; cleared on divergence
|
||||
|
||||
// Glitch: when the server confirms a prediction, we note it so the
|
||||
// overlay can stop rendering that cell as "speculative".
|
||||
confirmed int // count of predictions confirmed so far this epoch
|
||||
}
|
||||
|
||||
type prediction struct {
|
||||
r rune
|
||||
x, y int // where we predicted it would land
|
||||
epoch int
|
||||
at time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
predictionTimeout = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// NewPredictor creates a new predictor.
|
||||
func NewPredictor() *Predictor {
|
||||
return &Predictor{}
|
||||
}
|
||||
|
||||
// KeystrokeFrom processes user input and returns any printable runes
|
||||
// that should be predicted. Non-printable input (control chars, escape
|
||||
// sequences) clears predictions since we can't know what the server
|
||||
// will do.
|
||||
func (p *Predictor) Keystroke(input []byte) {
|
||||
i := 0
|
||||
for i < len(input) {
|
||||
r, size := utf8.DecodeRune(input[i:])
|
||||
i += size
|
||||
if r == utf8.RuneError {
|
||||
p.Reset()
|
||||
return
|
||||
}
|
||||
|
||||
if r < 0x20 || r == 0x7f {
|
||||
// Control character — we can't predict what happens.
|
||||
// Backspace, enter, tab, escape sequences, etc.
|
||||
p.Reset()
|
||||
return
|
||||
}
|
||||
|
||||
if unicode.IsPrint(r) {
|
||||
p.pending = append(p.pending, prediction{
|
||||
r: r,
|
||||
x: p.curX,
|
||||
y: p.curY,
|
||||
epoch: p.epoch,
|
||||
at: time.Now(),
|
||||
})
|
||||
p.curX++
|
||||
p.active = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset clears all predictions (e.g., on control characters or server divergence).
|
||||
func (p *Predictor) Reset() {
|
||||
p.pending = p.pending[:0]
|
||||
p.epoch++
|
||||
p.active = false
|
||||
p.confirmed = 0
|
||||
}
|
||||
|
||||
// SetCursor updates the predicted cursor position from the server state.
|
||||
// Call this when processing server output so predictions know where
|
||||
// the cursor is.
|
||||
func (p *Predictor) SetCursor(x, y int) {
|
||||
if !p.active {
|
||||
p.curX = x
|
||||
p.curY = y
|
||||
}
|
||||
}
|
||||
|
||||
// Active returns true if there are pending predictions.
|
||||
func (p *Predictor) Active() bool {
|
||||
return p.active && len(p.pending) > 0
|
||||
}
|
||||
|
||||
// ExpireStale removes predictions older than the timeout.
|
||||
func (p *Predictor) ExpireStale(now time.Time) {
|
||||
cutoff := now.Add(-predictionTimeout)
|
||||
changed := false
|
||||
for len(p.pending) > 0 && p.pending[0].at.Before(cutoff) {
|
||||
p.pending = p.pending[1:]
|
||||
changed = true
|
||||
}
|
||||
if changed && len(p.pending) == 0 {
|
||||
p.active = false
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm checks the server framebuffer against pending predictions.
|
||||
// Confirmed predictions are removed. If the server shows something
|
||||
// different where we predicted, all predictions are cleared.
|
||||
func (p *Predictor) Confirm(fb *Framebuffer) {
|
||||
if !p.active || len(p.pending) == 0 {
|
||||
// No predictions, just track cursor.
|
||||
p.curX = fb.CurX
|
||||
p.curY = fb.CurY
|
||||
return
|
||||
}
|
||||
|
||||
// Walk through pending predictions and see if the server matches.
|
||||
confirmed := 0
|
||||
for confirmed < len(p.pending) {
|
||||
pred := &p.pending[confirmed]
|
||||
|
||||
if pred.epoch != p.epoch {
|
||||
// Stale prediction from before a reset.
|
||||
confirmed++
|
||||
continue
|
||||
}
|
||||
|
||||
cell := fb.CellAt(pred.x, pred.y)
|
||||
if cell == nil {
|
||||
// Out of bounds — clear everything.
|
||||
p.Reset()
|
||||
p.curX = fb.CurX
|
||||
p.curY = fb.CurY
|
||||
return
|
||||
}
|
||||
|
||||
if cell.Rune == pred.r {
|
||||
// Server confirmed this prediction.
|
||||
confirmed++
|
||||
} else if cell.Rune == ' ' || cell.Rune == 0 {
|
||||
// Server hasn't caught up yet — stop checking.
|
||||
break
|
||||
} else {
|
||||
// Server diverged — something unexpected happened
|
||||
// (tab completion, shell editing, etc.)
|
||||
p.Reset()
|
||||
p.curX = fb.CurX
|
||||
p.curY = fb.CurY
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if confirmed > 0 {
|
||||
p.pending = p.pending[confirmed:]
|
||||
p.confirmed += confirmed
|
||||
}
|
||||
|
||||
if len(p.pending) == 0 {
|
||||
// All predictions confirmed. Sync cursor to server.
|
||||
p.active = false
|
||||
p.curX = fb.CurX
|
||||
p.curY = fb.CurY
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay applies pending predictions onto a framebuffer for display.
|
||||
// The framebuffer is modified in-place — call this on a copy if needed.
|
||||
// Predicted characters get an underline attribute to indicate they're
|
||||
// speculative (matching C++ mosh behavior).
|
||||
func (p *Predictor) Overlay(fb *Framebuffer) {
|
||||
if !p.active {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range p.pending {
|
||||
pred := &p.pending[i]
|
||||
if pred.epoch != p.epoch {
|
||||
continue
|
||||
}
|
||||
cell := fb.CellAt(pred.x, pred.y)
|
||||
if cell == nil {
|
||||
continue
|
||||
}
|
||||
cell.Rune = pred.r
|
||||
cell.Width = 1
|
||||
cell.Attr.Under = true // underline marks speculative characters
|
||||
}
|
||||
|
||||
// Move cursor to predicted position.
|
||||
if len(p.pending) > 0 {
|
||||
fb.CurX = p.curX
|
||||
fb.CurY = p.curY
|
||||
}
|
||||
}
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
package mosh
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestPredictorBasicEcho(t *testing.T) {
|
||||
p := NewPredictor()
|
||||
p.SetCursor(0, 0)
|
||||
|
||||
// Type "abc"
|
||||
p.Keystroke([]byte("a"))
|
||||
p.Keystroke([]byte("b"))
|
||||
p.Keystroke([]byte("c"))
|
||||
|
||||
if !p.Active() {
|
||||
t.Fatal("predictor should be active after typing")
|
||||
}
|
||||
if len(p.pending) != 3 {
|
||||
t.Fatalf("expected 3 pending, got %d", len(p.pending))
|
||||
}
|
||||
if p.pending[0].r != 'a' || p.pending[0].x != 0 || p.pending[0].y != 0 {
|
||||
t.Fatalf("prediction 0: got %+v", p.pending[0])
|
||||
}
|
||||
if p.pending[1].r != 'b' || p.pending[1].x != 1 {
|
||||
t.Fatalf("prediction 1: got %+v", p.pending[1])
|
||||
}
|
||||
if p.pending[2].r != 'c' || p.pending[2].x != 2 {
|
||||
t.Fatalf("prediction 2: got %+v", p.pending[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPredictorOverlay(t *testing.T) {
|
||||
p := NewPredictor()
|
||||
p.SetCursor(0, 0)
|
||||
p.Keystroke([]byte("hi"))
|
||||
|
||||
fb := NewFramebuffer(80, 24)
|
||||
p.Overlay(fb)
|
||||
|
||||
c0 := fb.CellAt(0, 0)
|
||||
c1 := fb.CellAt(1, 0)
|
||||
if c0.Rune != 'h' || !c0.Attr.Under {
|
||||
t.Fatalf("cell 0: got %+v, want 'h' underlined", c0)
|
||||
}
|
||||
if c1.Rune != 'i' || !c1.Attr.Under {
|
||||
t.Fatalf("cell 1: got %+v, want 'i' underlined", c1)
|
||||
}
|
||||
if fb.CurX != 2 || fb.CurY != 0 {
|
||||
t.Fatalf("cursor: got (%d,%d), want (2,0)", fb.CurX, fb.CurY)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPredictorConfirmAll(t *testing.T) {
|
||||
p := NewPredictor()
|
||||
p.SetCursor(0, 0)
|
||||
p.Keystroke([]byte("ab"))
|
||||
|
||||
// Simulate server confirming "ab" at positions 0,1
|
||||
fb := NewFramebuffer(80, 24)
|
||||
fb.CellAt(0, 0).Rune = 'a'
|
||||
fb.CellAt(1, 0).Rune = 'b'
|
||||
fb.CurX = 2
|
||||
fb.CurY = 0
|
||||
|
||||
p.Confirm(fb)
|
||||
if p.Active() {
|
||||
t.Fatal("predictor should be inactive after full confirmation")
|
||||
}
|
||||
if len(p.pending) != 0 {
|
||||
t.Fatalf("expected 0 pending, got %d", len(p.pending))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPredictorPartialConfirm(t *testing.T) {
|
||||
p := NewPredictor()
|
||||
p.SetCursor(0, 0)
|
||||
p.Keystroke([]byte("abc"))
|
||||
|
||||
// Server has confirmed "a" but not "b" or "c" yet
|
||||
fb := NewFramebuffer(80, 24)
|
||||
fb.CellAt(0, 0).Rune = 'a'
|
||||
fb.CurX = 1
|
||||
fb.CurY = 0
|
||||
|
||||
p.Confirm(fb)
|
||||
if !p.Active() {
|
||||
t.Fatal("predictor should still be active with pending predictions")
|
||||
}
|
||||
if len(p.pending) != 2 {
|
||||
t.Fatalf("expected 2 pending, got %d", len(p.pending))
|
||||
}
|
||||
if p.pending[0].r != 'b' {
|
||||
t.Fatalf("first remaining prediction: got %c, want b", p.pending[0].r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPredictorDivergence(t *testing.T) {
|
||||
p := NewPredictor()
|
||||
p.SetCursor(0, 0)
|
||||
p.Keystroke([]byte("abc"))
|
||||
|
||||
// Server shows 'x' where we predicted 'a' — tab completion, etc.
|
||||
fb := NewFramebuffer(80, 24)
|
||||
fb.CellAt(0, 0).Rune = 'x'
|
||||
fb.CurX = 5
|
||||
fb.CurY = 0
|
||||
|
||||
p.Confirm(fb)
|
||||
if p.Active() {
|
||||
t.Fatal("predictor should reset on divergence")
|
||||
}
|
||||
if p.curX != 5 {
|
||||
t.Fatalf("cursor should sync to server: got %d, want 5", p.curX)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPredictorControlCharResets(t *testing.T) {
|
||||
p := NewPredictor()
|
||||
p.SetCursor(0, 0)
|
||||
p.Keystroke([]byte("ab"))
|
||||
|
||||
if !p.Active() {
|
||||
t.Fatal("should be active")
|
||||
}
|
||||
|
||||
// Send a control character (enter)
|
||||
p.Keystroke([]byte("\n"))
|
||||
if p.Active() {
|
||||
t.Fatal("control char should reset predictions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPredictorEscapeSequenceResets(t *testing.T) {
|
||||
p := NewPredictor()
|
||||
p.SetCursor(0, 0)
|
||||
p.Keystroke([]byte("ab"))
|
||||
|
||||
// Escape byte
|
||||
p.Keystroke([]byte{0x1b})
|
||||
if p.Active() {
|
||||
t.Fatal("escape should reset predictions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPredictorExpireStale(t *testing.T) {
|
||||
p := NewPredictor()
|
||||
p.SetCursor(0, 0)
|
||||
p.Keystroke([]byte("a"))
|
||||
|
||||
// Manually backdate the prediction
|
||||
p.pending[0].at = time.Now().Add(-600 * time.Millisecond)
|
||||
|
||||
p.ExpireStale(time.Now())
|
||||
if p.Active() {
|
||||
t.Fatal("stale prediction should be expired")
|
||||
}
|
||||
if len(p.pending) != 0 {
|
||||
t.Fatalf("expected 0 pending, got %d", len(p.pending))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPredictorNoActivityNoCursorOverride(t *testing.T) {
|
||||
p := NewPredictor()
|
||||
|
||||
// Without any predictions, SetCursor should track server
|
||||
p.SetCursor(10, 5)
|
||||
if p.curX != 10 || p.curY != 5 {
|
||||
t.Fatalf("cursor not tracking: got (%d,%d)", p.curX, p.curY)
|
||||
}
|
||||
|
||||
// After typing, SetCursor should not override predicted position
|
||||
p.Keystroke([]byte("x"))
|
||||
p.SetCursor(0, 0)
|
||||
if p.curX != 11 {
|
||||
t.Fatalf("predicted cursor should not be overridden: got %d, want 11", p.curX)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPredictorMultibyteUTF8(t *testing.T) {
|
||||
p := NewPredictor()
|
||||
p.SetCursor(0, 0)
|
||||
p.Keystroke([]byte("é")) // 2 bytes
|
||||
|
||||
if len(p.pending) != 1 {
|
||||
t.Fatalf("expected 1 prediction, got %d", len(p.pending))
|
||||
}
|
||||
if p.pending[0].r != 'é' {
|
||||
t.Fatalf("got %c, want é", p.pending[0].r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPredictorOverlayDoesNotAffectUnpredictedCells(t *testing.T) {
|
||||
p := NewPredictor()
|
||||
p.SetCursor(5, 0)
|
||||
p.Keystroke([]byte("x"))
|
||||
|
||||
fb := NewFramebuffer(80, 24)
|
||||
fb.CellAt(0, 0).Rune = 'A'
|
||||
fb.CellAt(1, 0).Rune = 'B'
|
||||
|
||||
p.Overlay(fb)
|
||||
|
||||
// Unpredicted cells should be untouched
|
||||
if fb.CellAt(0, 0).Rune != 'A' {
|
||||
t.Fatal("cell 0 should be unchanged")
|
||||
}
|
||||
if fb.CellAt(1, 0).Rune != 'B' {
|
||||
t.Fatal("cell 1 should be unchanged")
|
||||
}
|
||||
// Predicted cell
|
||||
if fb.CellAt(5, 0).Rune != 'x' {
|
||||
t.Fatal("cell 5 should be 'x'")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPredictorE2EGoServer tests prediction with real mosh transport.
|
||||
func TestPredictorE2EGoServer(t *testing.T) {
|
||||
srv, err := NewServer("/bin/sh", 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go srv.Serve()
|
||||
defer srv.Close()
|
||||
<-srv.started
|
||||
|
||||
client, err := Dial("127.0.0.1", srv.Port(), srv.KeyBase64())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Wait for shell prompt.
|
||||
for i := 0; i < 40; i++ {
|
||||
if out := client.Recv(500 * time.Millisecond); len(out) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
pred := NewPredictor()
|
||||
pred.SetCursor(0, 0)
|
||||
|
||||
// Type printable chars — predictions should appear immediately.
|
||||
keys := []byte("echo hello")
|
||||
pred.Keystroke(keys)
|
||||
client.Send(keys)
|
||||
|
||||
if !pred.Active() {
|
||||
t.Fatal("predictor should be active after typing")
|
||||
}
|
||||
if len(pred.pending) != 10 {
|
||||
t.Fatalf("expected 10 predictions, got %d", len(pred.pending))
|
||||
}
|
||||
|
||||
// The predictions can be overlaid on a framebuffer immediately —
|
||||
// this is the "instant echo" the user sees.
|
||||
fb := NewFramebuffer(80, 24)
|
||||
pred.Overlay(fb)
|
||||
if fb.CellAt(0, 0).Rune != 'e' || fb.CellAt(1, 0).Rune != 'c' {
|
||||
t.Fatal("overlay should show predicted characters immediately")
|
||||
}
|
||||
|
||||
// Wait for server to confirm. The server will echo the characters
|
||||
// and predictions should get confirmed.
|
||||
deadline := time.After(10 * time.Second)
|
||||
for pred.Active() {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("predictions not confirmed. %d still pending", len(pred.pending))
|
||||
default:
|
||||
}
|
||||
out := client.Recv(200 * time.Millisecond)
|
||||
if out != nil {
|
||||
// Parse server output into a framebuffer to confirm predictions.
|
||||
// For simplicity, build a minimal framebuffer from ANSI output.
|
||||
serverFB := NewFramebuffer(80, 24)
|
||||
applyANSIToFB(serverFB, out)
|
||||
pred.Confirm(serverFB)
|
||||
}
|
||||
pred.ExpireStale(time.Now())
|
||||
}
|
||||
|
||||
t.Log("prediction E2E with Go server passed")
|
||||
}
|
||||
|
||||
// applyANSIToFB is a minimal helper that writes characters from ANSI
|
||||
// output into a framebuffer. It doesn't parse escape sequences — just
|
||||
// extracts printable characters at sequential positions. Good enough
|
||||
// for testing prediction confirmation.
|
||||
func applyANSIToFB(fb *Framebuffer, data []byte) {
|
||||
x, y := 0, 0
|
||||
i := 0
|
||||
for i < len(data) {
|
||||
if data[i] == 0x1b {
|
||||
// Skip escape sequence
|
||||
i++
|
||||
if i < len(data) && data[i] == '[' {
|
||||
i++
|
||||
for i < len(data) && !((data[i] >= 'A' && data[i] <= 'Z') || (data[i] >= 'a' && data[i] <= 'z')) {
|
||||
i++
|
||||
}
|
||||
if i < len(data) {
|
||||
i++ // skip final byte
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if data[i] == '\r' {
|
||||
x = 0
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if data[i] == '\n' {
|
||||
y++
|
||||
i++
|
||||
continue
|
||||
}
|
||||
r, size := utf8.DecodeRune(data[i:])
|
||||
i += size
|
||||
if r >= 0x20 && x < fb.W && y < fb.H {
|
||||
cell := fb.CellAt(x, y)
|
||||
cell.Rune = r
|
||||
cell.Width = 1
|
||||
x++
|
||||
}
|
||||
}
|
||||
fb.CurX = x
|
||||
fb.CurY = y
|
||||
}
|
||||
Reference in New Issue
Block a user