From dbd0fdb3b618f9903e21381479948b2510004e4f Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Thu, 24 Sep 2026 13:03:47 +0300 Subject: [PATCH] viewer: probe terminal colors and send them in the attach frame --- internal/app/app.go | 75 +++++++++- internal/app/app_test.go | 30 ++++ internal/session/session.go | 5 +- internal/termcolor/termcolor.go | 216 +++++++++++++++++++++++++++ internal/termcolor/termcolor_test.go | 98 ++++++++++++ 5 files changed, 415 insertions(+), 9 deletions(-) create mode 100644 internal/termcolor/termcolor.go create mode 100644 internal/termcolor/termcolor_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 725386a..ea1837a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -18,6 +18,7 @@ import ( "tobi/internal/remote" "tobi/internal/session" "tobi/internal/target" + "tobi/internal/termcolor" ) func Find(ctx context.Context, query string) []session.Session { @@ -138,18 +139,32 @@ func RunHopLoop(dst session.Session) error { } inputCh := make(chan []byte, 32) + probed := make(chan map[int]termcolor.Color, 1) go func() { defer close(inputCh) - buf := make([]byte, 4096) - for { - switch n, err := os.Stdin.Read(buf); { - case n > 0: - inputCh <- slices.Clone(buf[:n]) - case err != nil: + reads := make(chan stdinRead) + go func() { + buf := make([]byte, 4096) + for { + n, err := os.Stdin.Read(buf) + reads <- stdinRead{slices.Clone(buf[:n]), err} + if err != nil { + return + } + } + }() + colors := probeTerminal(reads, func(b []byte) { inputCh <- b }) + probed <- colors + for r := range reads { + if len(r.b) > 0 { + inputCh <- r.b + } + if r.err != nil { return } } }() + colors := <-probed dial := func(s session.Session) (io.ReadWriteCloser, error) { if isLocal(s.Host) { @@ -165,7 +180,7 @@ func RunHopLoop(dst session.Session) error { return suggest(fmt.Errorf("connect %s: %w", current.Canonical(), err), current.Canonical()) } - if err = session.Attach(stream, inputCh); err == nil || err == session.ErrDetached { + if err = session.Attach(stream, inputCh, colors); err == nil || err == session.ErrDetached { return nil } hop, ok := err.(session.HopRequest) @@ -180,6 +195,52 @@ func RunHopLoop(dst session.Session) error { } } +type stdinRead struct { + b []byte + err error +} + +// probeTerminal asks the real terminal for its colors and collects replies +// until the da1 sentinel arrives or the window closes. it runs inside the +// stdin reader goroutine before any attach, so replies can never reach a +// session as input. keys typed during the probe are queued for the input +// loop. it returns nil and skips the probe when stdin or stdout is not a +// terminal. +func probeTerminal(reads <-chan stdinRead, push func([]byte)) map[int]termcolor.Color { + if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) { + return nil + } + return runProbe(reads, os.Stdout, push, probeWindow) +} + +const probeWindow = 300 * time.Millisecond + +func runProbe(reads <-chan stdinRead, w io.Writer, push func([]byte), window time.Duration) map[int]termcolor.Color { + if _, err := w.Write([]byte(termcolor.ProbeQuery)); err != nil { + return nil + } + var acc []byte + timer := time.NewTimer(window) + defer timer.Stop() + for { + select { + case r := <-reads: + acc = append(acc, r.b...) + if end := termcolor.DA1End(acc); end >= 0 { + if end < len(acc) { + push(acc[end:]) + } + return termcolor.ParseReplies(acc[:end]) + } + if r.err != nil { + return termcolor.ParseReplies(acc) + } + case <-timer.C: + return termcolor.ParseReplies(acc) + } + } +} + func isLocal(host string) bool { return strings.EqualFold(host, session.ShortHostname()) || strings.EqualFold(host, "localhost") } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 96ae695..e5ecba5 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2,6 +2,7 @@ package app import ( "errors" + "io" "regexp" "strings" "testing" @@ -12,6 +13,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "tobi/internal/session" + "tobi/internal/termcolor" ) var ansi = regexp.MustCompile("\\x1b\\[[0-9;]*m") @@ -57,3 +59,31 @@ func TestKillFlow(t *testing.T) { t.Fatal("a killed session should leave its row") } } + +func TestRunProbeConsumesRepliesNotInput(t *testing.T) { + reads := make(chan stdinRead, 8) + reads <- stdinRead{[]byte("\x1b]11;rgb:2222/3333/4444\x1b\\"), nil} + reads <- stdinRead{[]byte("\x1b[?62;1cx"), nil} + reads <- stdinRead{[]byte("x"), nil} + var pushed []string + colors := runProbe(reads, io.Discard, func(b []byte) { pushed = append(pushed, string(b)) }, 50*time.Millisecond) + if colors[termcolor.Background] != (termcolor.Color{R: 0x2222, G: 0x3333, B: 0x4444}) { + t.Fatalf("probe colors = %v", colors) + } + if len(pushed) != 1 || pushed[0] != "x" { + t.Fatalf("probe pushed %q, want only the keys after the sentinel", pushed) + } +} + +func TestRunProbeWritesQueryAndTimesOut(t *testing.T) { + reads := make(chan stdinRead, 8) + reads <- stdinRead{[]byte("\x1b]10;rgb:0102/0304/0506\x1b\\"), nil} + var w strings.Builder + colors := runProbe(reads, &w, func([]byte) { t.Fatal("nothing to push on timeout") }, 50*time.Millisecond) + if w.String() != termcolor.ProbeQuery { + t.Fatalf("probe wrote %q", w.String()) + } + if colors[termcolor.Foreground] != (termcolor.Color{R: 0x0102, G: 0x0304, B: 0x0506}) { + t.Fatalf("probe colors = %v", colors) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 4475af3..5792016 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -25,6 +25,7 @@ import ( "github.com/samber/lo" "golang.org/x/term" "tobi/internal/protocol" + "tobi/internal/termcolor" ) var ( @@ -194,11 +195,11 @@ func ListLocal() []Session { return list } -func Attach(stream io.ReadWriteCloser, inputCh <-chan []byte) error { +func Attach(stream io.ReadWriteCloser, inputCh <-chan []byte, colors map[int]termcolor.Color) error { defer stream.Close() size := func(t protocol.FrameType) error { c, r, _ := term.GetSize(int(os.Stdout.Fd())) - return protocol.WriteFrame(stream, t, protocol.EncodeSize(c, r)) + return protocol.WriteFrame(stream, t, append(protocol.EncodeSize(c, r), termcolor.EncodeColors(colors)...)) } if err := size(protocol.TypeAttach); err != nil { return err diff --git a/internal/termcolor/termcolor.go b/internal/termcolor/termcolor.go new file mode 100644 index 0000000..b6abc2b --- /dev/null +++ b/internal/termcolor/termcolor.go @@ -0,0 +1,216 @@ +// package termcolor carries the terminal color query flow: parsing a real +// terminal's osc 4/10/11/12 replies, the attach payload section that carries +// them to the owner, and the agreement rule the owner applies before +// answering a query from an app inside the session. +package termcolor + +import ( + "encoding/binary" + "fmt" + "maps" + "slices" + "strconv" + "strings" +) + +// color keys follow the xterm index space: 0-255 is the palette, 256 and up +// are the special slots. +const ( + Foreground = 256 + Background = 257 + Cursor = 258 +) + +type Color struct{ R, G, B uint16 } + +// ProbeQuery asks the terminal for its foreground, background, cursor, the +// first 16 palette entries, and a da1 sentinel to bound the wait. +var ProbeQuery = buildProbeQuery() + +func buildProbeQuery() string { + q := "\x1b]10;?\x1b\\\x1b]11;?\x1b\\\x1b]12;?\x1b\\" + pairs := make([]string, 0, 32) + for i := range 16 { + pairs = append(pairs, strconv.Itoa(i), "?") + } + return q + "\x1b]4;" + strings.Join(pairs, ";") + "\x1b\\\x1b[c" +} + +// ParseReplies extracts color reports from a terminal's replies to ProbeQuery. +// malformed or unrelated escape sequences are ignored. +func ParseReplies(b []byte) map[int]Color { + colors := map[int]Color{} + for i := 0; i+1 < len(b); { + if b[i] != 0x1b || b[i+1] != ']' { + i++ + continue + } + body, next, ok := oscSpan(b, i) + if !ok { + if next >= len(b) { + break // incomplete tail + } + i = next + continue + } + if k, c, ok := parseReply(body); ok { + colors[k] = c + } + i = next + } + return colors +} + +// oscSpan reads one osc string starting at b[i] (esc ]) and returns its body +// plus the index just past it. BEL and ST both terminate. +func oscSpan(b []byte, i int) (body string, next int, ok bool) { + for j := i + 2; j < len(b); j++ { + switch { + case b[j] == '\a': + return string(b[i+2 : j]), j + 1, true + case b[j] == 0x1b && j+1 < len(b) && b[j+1] == '\\': + return string(b[i+2 : j]), j + 2, true + case b[j] == 0x1b: + return "", j, false + } + } + return "", len(b), false +} + +func parseReply(body string) (int, Color, bool) { + parts := strings.Split(body, ";") + switch parts[0] { + case "4": + if len(parts) != 3 { + return 0, Color{}, false + } + i, err := strconv.Atoi(parts[1]) + if err != nil || i < 0 || i >= Foreground { + return 0, Color{}, false + } + c, ok := parseColor(parts[2]) + return i, c, ok + case "10", "11", "12": + if len(parts) != 2 { + return 0, Color{}, false + } + n, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, Color{}, false + } + c, ok := parseColor(parts[1]) + return Foreground + n - 10, c, ok + } + return 0, Color{}, false +} + +func parseColor(s string) (Color, bool) { + spec, ok := strings.CutPrefix(s, "rgb:") + if !ok { + return Color{}, false + } + parts := strings.Split(spec, "/") + if len(parts) != 3 { + return Color{}, false + } + var ch [3]uint16 + for i, p := range parts { + if len(p) < 1 || len(p) > 4 { + return Color{}, false + } + v, err := strconv.ParseUint(p, 16, 32) + if err != nil { + return Color{}, false + } + max := uint64(1)<<(4*len(p)) - 1 + ch[i] = uint16(v * 0xffff / max) + } + return Color{ch[0], ch[1], ch[2]}, true +} + +// DA1End returns the index just past a device attributes reply, the probe's +// sentinel, or -1 when none has arrived yet. +func DA1End(b []byte) int { + for i := 0; i+1 < len(b); i++ { + if b[i] != 0x1b || b[i+1] != '[' { + continue + } + j := i + 2 + for j < len(b) && b[j] >= 0x20 && b[j] <= 0x3f { + j++ + } + if j < len(b) && b[j] == 'c' { + return j + 1 + } + } + return -1 +} + +const colorsVersion = 1 + +// EncodeColors packs a color map as the attach payload section that follows +// the four size bytes. an empty map encodes to nothing, which leaves the +// payload in the legacy four byte shape. +func EncodeColors(colors map[int]Color) []byte { + if len(colors) == 0 { + return nil + } + keys := slices.Sorted(maps.Keys(colors)) + b := []byte{colorsVersion, byte(len(keys))} + for _, k := range keys { + c := colors[k] + b = binary.BigEndian.AppendUint16(b, uint16(k)) + b = binary.BigEndian.AppendUint16(b, c.R) + b = binary.BigEndian.AppendUint16(b, c.G) + b = binary.BigEndian.AppendUint16(b, c.B) + } + return b +} + +// DecodeColors reads the colors section of an attach payload. ok is false for +// legacy payloads that carry no section. +func DecodeColors(b []byte) (map[int]Color, bool) { + if len(b) < 2 || b[0] != colorsVersion { + return nil, false + } + n := int(b[1]) + if len(b) != 2+8*n { + return nil, false + } + colors := make(map[int]Color, n) + for i := range n { + o := 2 + 8*i + colors[int(binary.BigEndian.Uint16(b[o:]))] = Color{ + R: binary.BigEndian.Uint16(b[o+2:]), + G: binary.BigEndian.Uint16(b[o+4:]), + B: binary.BigEndian.Uint16(b[o+6:]), + } + } + return colors, true +} + +// Agreed answers a color query only when every viewer reported the color and +// all reported the same value. zero viewers, a missing color, or a mismatch +// all leave the query unanswered. +func Agreed(index int, sets []map[int]Color) (Color, bool) { + var first Color + for i, set := range sets { + c, ok := set[index] + if !ok { + return Color{}, false + } + if i > 0 && c != first { + return Color{}, false + } + first = c + } + return first, len(sets) > 0 +} + +// Reply formats the osc response an owner writes for a color query. +func Reply(index int, c Color) string { + if index >= Foreground { + return fmt.Sprintf("\x1b]%d;rgb:%04x/%04x/%04x\x1b\\", 10+index-Foreground, c.R, c.G, c.B) + } + return fmt.Sprintf("\x1b]4;%d;rgb:%04x/%04x/%04x\x1b\\", index, c.R, c.G, c.B) +} diff --git a/internal/termcolor/termcolor_test.go b/internal/termcolor/termcolor_test.go new file mode 100644 index 0000000..35df04d --- /dev/null +++ b/internal/termcolor/termcolor_test.go @@ -0,0 +1,98 @@ +package termcolor + +import ( + "maps" + "testing" +) + +func TestParseReplies(t *testing.T) { + for _, tc := range []struct { + name string + in string + want map[int]Color + }{ + {"four digit channels with st", "\x1b]10;rgb:cccc/cccc/cccc\x1b\\\x1b]11;rgb:1c1c/2a2a/3c3c\x1b\\", map[int]Color{ + Foreground: {0xcccc, 0xcccc, 0xcccc}, Background: {0x1c1c, 0x2a2a, 0x3c3c}}}, + {"two digit channels with bel", "\x1b]4;5;rgb:ff/00/80\a\x1b]12;rgb:11/22/33\a", map[int]Color{ + 5: {0xffff, 0, 0x8080}, Cursor: {0x1111, 0x2222, 0x3333}}}, + {"one digit channels", "\x1b]4;0;rgb:f/0/a\x1b\\", map[int]Color{0: {0xffff, 0, 0xaaaa}}}, + {"three digit channels", "\x1b]4;1;rgb:fff/000/fff\x1b\\", map[int]Color{1: {0xffff, 0, 0xffff}}}, + {"sentinel and junk around replies", "\x1b[?62;1c\x1b]4;1;rgb:8888/8888/8888\x1b\\junk", map[int]Color{ + 1: {0x8888, 0x8888, 0x8888}}}, + {"incomplete reply ignored", "\x1b]10;rgb:cccc/c", nil}, + {"unrelated osc ignored", "\x1b]7;file:///tmp\x1b\\\x1b[?1;2c", nil}, + } { + got := ParseReplies([]byte(tc.in)) + if len(got) != len(tc.want) { + t.Fatalf("%s: ParseReplies(%q) = %v, want %v", tc.name, tc.in, got, tc.want) + } + for k, v := range tc.want { + if got[k] != v { + t.Fatalf("%s: ParseReplies(%q)[%d] = %v, want %v", tc.name, tc.in, k, got[k], v) + } + } + } +} + +func TestDA1End(t *testing.T) { + in := "\x1b]10;?x\x1b[?62;22c rest" + if got, want := DA1End([]byte(in)), len("\x1b]10;?x\x1b[?62;22c"); got != want { + t.Fatalf("DA1End = %d, want %d", got, want) + } + if DA1End([]byte("\x1b]10;rgb:0000/0000/0000\x1b\\")) != -1 { + t.Fatal("a color reply is not the sentinel") + } + if DA1End([]byte("\x1b[?62;")) != -1 { + t.Fatal("an incomplete sentinel must not match") + } +} + +func TestEncodeColorsRoundTrip(t *testing.T) { + in := map[int]Color{Foreground: {0xffff, 0xffff, 0xffff}, 5: {0x8000, 0, 0x8000}, Background: {0, 0, 0}} + out, ok := DecodeColors(EncodeColors(in)) + if !ok || !maps.Equal(out, in) { + t.Fatalf("round trip = %v %v, want %v", out, ok, in) + } + if EncodeColors(nil) != nil { + t.Fatal("no colors must encode to nothing so the payload stays legacy") + } + if _, ok := DecodeColors(nil); ok { + t.Fatal("a legacy payload must decode as unknown") + } + if _, ok := DecodeColors([]byte{colorsVersion + 1, 1}); ok { + t.Fatal("an unknown version must decode as unknown") + } + if _, ok := DecodeColors([]byte{colorsVersion, 2, 0, 0, 0, 0, 0, 0, 0}); ok { + t.Fatal("a truncated section must decode as unknown") + } +} + +func TestAgreed(t *testing.T) { + a := map[int]Color{Foreground: {1, 2, 3}, Background: {4, 5, 6}} + b := map[int]Color{Foreground: {1, 2, 3}, Background: {7, 8, 9}} + if c, ok := Agreed(Foreground, []map[int]Color{a, b}); !ok || c != (Color{1, 2, 3}) { + t.Fatalf("agreed foreground = %v %v", c, ok) + } + if _, ok := Agreed(Background, []map[int]Color{a, b}); ok { + t.Fatal("a disagreement must stay silent") + } + if _, ok := Agreed(Cursor, []map[int]Color{a, b}); ok { + t.Fatal("a color nobody reported must stay silent") + } + if _, ok := Agreed(Foreground, []map[int]Color{a, nil}); ok { + t.Fatal("a viewer without colors must stay silent") + } + if _, ok := Agreed(Foreground, nil); ok { + t.Fatal("zero viewers must stay silent") + } +} + +func TestReply(t *testing.T) { + c := Color{0x1234, 0x5678, 0x9abc} + if got, want := Reply(5, c), "\x1b]4;5;rgb:1234/5678/9abc\x1b\\"; got != want { + t.Fatalf("Reply(5) = %q, want %q", got, want) + } + if got, want := Reply(Background, c), "\x1b]11;rgb:1234/5678/9abc\x1b\\"; got != want { + t.Fatalf("Reply(Background) = %q, want %q", got, want) + } +} -- 2.51.2