viewer: probe terminal colors and send them in the attach frame
16 unmodified lines171819202122232425112 unmodified linesExpand all1381391401411421431441431441451461471481451461471481491501511521531541551561571581591601611621631641651661671681691701717 unmodified lines1791801811821681831841851861876 unmodified lines19419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624763 unmodified lines16 unmodified lines "golang.org/x/term" "tobi/internal/remote" "tobi/internal/session" "tobi/internal/target" "tobi/internal/termcolor")
func Find(ctx context.Context, query string) []session.Session { return findSessions(ctx, query, remote.DiscoverPeers(ctx))112 unmodified linesExpand all defer term.Restore(int(os.Stdin.Fd()), rawState) }
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) { return net.Dial("unix", session.SocketPath(s.ID))7 unmodified lines if err != nil { 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) if !ok {6 unmodified lines current = session.Session{Host: t.Dest(), ID: t.ID} }}
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")}
63 unmodified lines1234567892 unmodified lines12131415161718192037 unmodified lines5859606162636465666768697071727374757677787980818283848586878889package app
import ( "errors" "io" "regexp" "strings" "testing" "time"2 unmodified lines "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "tobi/internal/session" "tobi/internal/termcolor")
var ansi = regexp.MustCompile("\\x1b\\[[0-9;]*m")
37 unmodified lines if m = next.(pickerModel); len(m.rows[0].sessions) != 0 { 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) }}23 unmodified lines242526272829303132161 unmodified linesExpand all194195196197197198199200201201202203204205206521 unmodified linesExpand all23 unmodified lines xterm "github.com/gitpod-io/xterm-go" "github.com/samber/lo" "golang.org/x/term" "tobi/internal/protocol" "tobi/internal/termcolor")
var ( ErrDetached = errors.New("session detached")161 unmodified linesExpand all } 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 }521 unmodified linesExpand all123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216// 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)}1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798package 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) }}