Terminal duplicates CSI 6n response after OSC 11 query, leaking escape replies into shell input
Summary
Superset appears to duplicate terminal cursor-position responses when a foreground process sends an OSC 11 background-color query immediately followed by a CSI 6n cursor-position query.
This breaks terminal capability probing used by Go terminal libraries such as termenv, lipgloss, and bubbletea. In real CLI usage, the extra terminal response can leak into the next shell prompt as text like:
execute: 1515/1111/1010\[4;1R[4;1R_
The same repro behaves correctly in kitty directly on the same machine.
This report was drafted with assistance from OpenAI Codex. The repro commands and observed outputs below were run manually by Wes McKinney.
Environment
- Superset terminal session
- Shell: zsh
TERM=xterm-256color
TMUX unset in the minimal repro
- Comparison terminal: kitty directly on the same machine
- macOS host
Minimal Repro
This self-contained Go program opens /dev/tty, sets non-canonical/no-echo mode, sends terminal queries, and reads responses back from /dev/tty.
It supports three modes:
/tmp/term-query-repro osc
/tmp/term-query-repro cpr
/tmp/term-query-repro both
Queries:
osc: sends ESC ] 11 ; ? ESC \
cpr: sends ESC [ 6 n
both: sends ESC ] 11 ; ? ESC \ ESC [ 6 n
Repro source
package main
import (
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"
)
func main() {
mode := "both"
if len(os.Args) > 1 {
mode = os.Args[1]
}
if mode != "both" && mode != "osc" && mode != "cpr" {
fmt.Fprintf(os.Stderr, "usage: %s [both|osc|cpr]\n", os.Args[0])
os.Exit(2)
}
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
fmt.Fprintf(os.Stderr, "open /dev/tty: %v\n", err)
os.Exit(1)
}
defer tty.Close()
oldState, err := stty(tty, "-g")
if err != nil {
fmt.Fprintf(os.Stderr, "stty -g: %v\n", err)
os.Exit(1)
}
defer func() { _, _ = stty(tty, oldState) }()
if _, err := stty(tty, "-echo", "-icanon", "min", "0", "time", "2"); err != nil {
fmt.Fprintf(os.Stderr, "stty raw-ish: %v\n", err)
os.Exit(1)
}
query, description := queryFor(mode)
fmt.Printf("pid=%d TERM=%q TMUX_set=%v mode=%s\n", os.Getpid(), os.Getenv("TERM"), os.Getenv("TMUX") != "", mode)
fmt.Println("sending " + description)
if _, err := tty.WriteString(query); err != nil {
fmt.Fprintf(os.Stderr, "write query: %v\n", err)
os.Exit(1)
}
deadline := time.Now().Add(2 * time.Second)
var got []byte
buf := make([]byte, 256)
for time.Now().Before(deadline) {
n, err := tty.Read(buf)
if n > 0 {
got = append(got, buf[:n]...)
if completeFor(mode, got) {
break
}
}
if err != nil && err != io.EOF {
fmt.Fprintf(os.Stderr, "read response: %v\n", err)
os.Exit(1)
}
time.Sleep(20 * time.Millisecond)
}
fmt.Printf("read %d bytes from /dev/tty\n", len(got))
fmt.Printf("raw: %q\n", got)
fmt.Printf("escaped: %s\n", escape(got))
fmt.Printf("OSC 11 response count: %d\n", countOSC11(got))
fmt.Printf("cursor-position response count: %d\n", countCPR(got))
fmt.Printf("has OSC 11 response: %v\n", hasOSC11(got))
fmt.Printf("has cursor-position response: %v\n", hasCPR(got))
fmt.Println("after this exits, type Enter. If query text appears at the next prompt, the response leaked.")
}
func queryFor(mode string) (string, string) {
switch mode {
case "osc":
return "\x1b]11;?\x1b\\", "OSC 11 background-color query only"
case "cpr":
return "\x1b[6n", "CSI 6n cursor-position query only"
default:
return "\x1b]11;?\x1b\\\x1b[6n", "OSC 11 background-color query and CSI 6n cursor-position query"
}
}
func completeFor(mode string, b []byte) bool {
switch mode {
case "osc":
return hasOSC11(b)
case "cpr":
return hasCPR(b)
default:
return hasOSC11(b) && hasCPR(b)
}
}
func stty(tty *os.File, args ...string) (string, error) {
cmd := exec.Command("stty", args...)
cmd.Stdin = tty
cmd.Stderr = os.Stderr
out, err := cmd.Output()
return strings.TrimSpace(string(out)), err
}
func hasOSC11(b []byte) bool {
return countOSC11(b) > 0
}
func hasCPR(b []byte) bool {
return countCPR(b) > 0
}
func countOSC11(b []byte) int {
return strings.Count(string(b), "\x1b]11;")
}
func countCPR(b []byte) int {
s := string(b)
count := 0
for i := 0; i < len(s); i++ {
if !strings.HasPrefix(s[i:], "\x1b[") {
continue
}
j := i + len("\x1b[")
for j < len(s) && (s[j] == ';' || (s[j] >= '0' && s[j] <= '9')) {
j++
}
if j < len(s) && s[j] == 'R' {
count++
i = j
}
}
return count
}
func escape(b []byte) string {
var out strings.Builder
for _, c := range b {
switch c {
case '\x1b':
out.WriteString("<ESC>")
case '\a':
out.WriteString("<BEL>")
case '\r':
out.WriteString("<CR>")
case '\n':
out.WriteString("<LF>")
default:
if c < 0x20 || c == 0x7f {
fmt.Fprintf(&out, "<0x%02x>", c)
} else {
out.WriteByte(c)
}
}
}
return out.String()
}
Expected Behavior
both should return exactly:
- one OSC 11 response
- one cursor-position response
Kitty direct example:
pid=60405 TERM="xterm-256color" TMUX_set=false
sending OSC 11 background-color query and CSI 6n cursor-position query
read 32 bytes from /dev/tty
raw: "\x1b]11;rgb:0000/0000/0000\x1b\\\x1b[88;1R"
escaped: <ESC>]11;rgb:0000/0000/0000<ESC>\<ESC>[88;1R
has OSC 11 response: true
has cursor-position response: true
No query response text should appear at the next shell prompt after the process exits.
Actual Behavior In Superset
osc alone behaves correctly:
$ /tmp/term-query-repro osc
pid=2306 TERM="xterm-256color" TMUX_set=false mode=osc
sending OSC 11 background-color query only
read 25 bytes from /dev/tty
raw: "\x1b]11;rgb:1515/1111/1010\x1b\\"
escaped: <ESC>]11;rgb:1515/1111/1010<ESC>\
OSC 11 response count: 1
cursor-position response count: 0
has OSC 11 response: true
has cursor-position response: false
cpr alone also returns one cursor-position response:
$ /tmp/term-query-repro cpr
pid=17734 TERM="xterm-256color" TMUX_set=false mode=cpr
sending CSI 6n cursor-position query only
read 7 bytes from /dev/tty
raw: "\x1b[51;1R"
escaped: <ESC>[51;1R
OSC 11 response count: 0
But both duplicates the cursor-position response:
$ /tmp/term-query-repro both
pid=5956 TERM="xterm-256color" TMUX_set=false mode=both
sending OSC 11 background-color query and CSI 6n cursor-position query
read 39 bytes from /dev/tty
raw: "\x1b]11;rgb:1515/1111/1010\x1b\\\x1b[35;1R\x1b[35;1R"
escaped: <ESC>]11;rgb:1515/1111/1010<ESC>\<ESC>[35;1R<ESC>[35;1R
OSC 11 response count: 1
cursor-position response count: 2
has OSC 11 response: true
has cursor-position response: true
The process only sent one CSI 6n query, so receiving two ESC[row;colR replies appears incorrect.
Real-World Impact
Libraries such as termenv intentionally send OSC 11 followed by CSI 6n. The cursor-position query is used as a synchronization marker so the process can know when terminal responses are complete.
If Superset sends two CPR replies for one CSI 6n query, programs that correctly read one OSC response and one CPR response can still leave an extra CPR in the terminal input stream. That extra response can then be consumed by the next shell prompt as user input.
This affects ordinary non-TUI commands too, because Bubble Tea / Lip Gloss / termenv can perform terminal probing before application command dispatch. I originally hit this via Git hooks invoking a Go CLI that imports Bubble Tea:
^[]11;rgb:1515/1111/1010^[\
^[[18;91R
execute: 1515/1111/1010\[18;145R[18;145R]...
Suspected Bug
Superset's terminal/session layer appears to forward or generate the CSI 6n cursor-position response twice when it immediately follows an OSC 11 query. The issue does not reproduce for OSC 11 alone or CSI 6n alone.
The duplicate response likely explains why terminal query responses sometimes leak into the next shell prompt/input line.
Terminal duplicates CSI 6n response after OSC 11 query, leaking escape replies into shell input
Summary
Superset appears to duplicate terminal cursor-position responses when a foreground process sends an OSC 11 background-color query immediately followed by a CSI 6n cursor-position query.
This breaks terminal capability probing used by Go terminal libraries such as
termenv,lipgloss, andbubbletea. In real CLI usage, the extra terminal response can leak into the next shell prompt as text like:The same repro behaves correctly in kitty directly on the same machine.
This report was drafted with assistance from OpenAI Codex. The repro commands and observed outputs below were run manually by Wes McKinney.
Environment
TERM=xterm-256colorTMUXunset in the minimal reproMinimal Repro
This self-contained Go program opens
/dev/tty, sets non-canonical/no-echo mode, sends terminal queries, and reads responses back from/dev/tty.It supports three modes:
Queries:
osc: sendsESC ] 11 ; ? ESC \cpr: sendsESC [ 6 nboth: sendsESC ] 11 ; ? ESC \ ESC [ 6 nRepro source
Expected Behavior
bothshould return exactly:Kitty direct example:
No query response text should appear at the next shell prompt after the process exits.
Actual Behavior In Superset
oscalone behaves correctly:cpralone also returns one cursor-position response:But
bothduplicates the cursor-position response:The process only sent one
CSI 6nquery, so receiving twoESC[row;colRreplies appears incorrect.Real-World Impact
Libraries such as
termenvintentionally send OSC 11 followed by CSI 6n. The cursor-position query is used as a synchronization marker so the process can know when terminal responses are complete.If Superset sends two CPR replies for one CSI 6n query, programs that correctly read one OSC response and one CPR response can still leave an extra CPR in the terminal input stream. That extra response can then be consumed by the next shell prompt as user input.
This affects ordinary non-TUI commands too, because Bubble Tea / Lip Gloss / termenv can perform terminal probing before application command dispatch. I originally hit this via Git hooks invoking a Go CLI that imports Bubble Tea:
Suspected Bug
Superset's terminal/session layer appears to forward or generate the CSI 6n cursor-position response twice when it immediately follows an OSC 11 query. The issue does not reproduce for OSC 11 alone or CSI 6n alone.
The duplicate response likely explains why terminal query responses sometimes leak into the next shell prompt/input line.