package status import ( "fmt" "regexp" "strconv" "strings" "unicode" ) const wideContinuation rune = -1 // RenderVisibleScreen runs the raw PTY stream through a small terminal screen // model and returns the visible text. func RenderVisibleScreen(raw string, rows, cols int) string { return renderVisibleScreen(raw, rows, cols) } // renderVisibleScreen runs the raw PTY stream through a small terminal screen // model (cursor + grid) and returns the visible text. Unlike cleanANSI which // strips control sequences in place, this honors cursor positioning and erase // commands so that text overwritten by later repaints disappears, matching // what a user would see in a terminal. The grid is sized large enough for // Claude's /usage panel (60 rows × 200 cols); content past the bottom row // scrolls up. // // Only the sequences Claude's /usage TUI is actually observed to emit are // implemented — CUP (H/f), CUU/CUD/CUF/CUB (A/B/C/D), CHA (G), ED (J), EL (K), // plus \r, \n, \b, \t. SGR (m), OSC, and DEC private modes are recognized and // discarded; unknown CSIs are skipped. func renderVisibleScreen(raw string, rows, cols int) string { scr := newScreen(rows, cols) rs := []rune(raw) for i := 0; i < len(rs); { c := rs[i] switch { case c == 0x1b: i++ if i >= len(rs) { break } switch rs[i] { case '[': i++ priv := byte(0) if i < len(rs) && (rs[i] == '?' || rs[i] == '>' || rs[i] == '!') { priv = byte(rs[i]) i++ } start := i for i < len(rs) && ((rs[i] >= '0' && rs[i] <= '9') || rs[i] == ';') { i++ } params := string(rs[start:i]) if i >= len(rs) { break } cmd := rs[i] i++ scr.handleCSI(priv, params, cmd) case ']': i++ for i < len(rs) { if rs[i] == 0x07 { i++ break } if rs[i] == 0x1b && i+1 < len(rs) && rs[i+1] == '\\' { i += 2 break } i++ } default: i++ } case c == '\r': scr.cx = 0 i++ case c == '\n': scr.cy++ if scr.cy >= scr.rows { scr.scrollUp() scr.cy = scr.rows - 1 } i++ case c == '\b': if scr.cx > 0 { scr.cx-- } i++ case c == '\t': scr.cx = ((scr.cx / 8) + 1) * 8 if scr.cx >= scr.cols { scr.cx = scr.cols - 1 } i++ case c < 0x20: i++ default: scr.putRune(c) i++ } } return scr.render() } type screen struct { rows, cols int grid [][]rune cy, cx int } func newScreen(rows, cols int) *screen { g := make([][]rune, rows) for i := range g { g[i] = make([]rune, cols) for j := range g[i] { g[i][j] = ' ' } } return &screen{rows: rows, cols: cols, grid: g} } func (s *screen) clampCursor() { if s.cy < 0 { s.cy = 0 } if s.cy >= s.rows { s.cy = s.rows - 1 } if s.cx < 0 { s.cx = 0 } if s.cx >= s.cols { s.cx = s.cols - 1 } } func (s *screen) putRune(r rune) { w := terminalRuneWidth(r) if w <= 0 { return } if s.cx >= s.cols { s.wrapLine() } if w > 1 && s.cx == s.cols-1 { s.wrapLine() } for i := 0; i < w && s.cx+i < s.cols; i++ { s.clearCell(s.cy, s.cx+i) } s.grid[s.cy][s.cx] = r if w > 1 && s.cx+1 < s.cols { s.grid[s.cy][s.cx+1] = wideContinuation } s.cx += w } func (s *screen) wrapLine() { s.cx = 0 s.cy++ if s.cy >= s.rows { s.scrollUp() s.cy = s.rows - 1 } } func (s *screen) clearCell(row, col int) { if row < 0 || row >= s.rows || col < 0 || col >= s.cols { return } if s.grid[row][col] == wideContinuation && col > 0 { s.grid[row][col-1] = ' ' } if col+1 < s.cols && s.grid[row][col+1] == wideContinuation { s.grid[row][col+1] = ' ' } s.grid[row][col] = ' ' } func terminalRuneWidth(r rune) int { if r == 0 || r < 0x20 { return 0 } if unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) { return 0 } if unicode.In(r, unicode.Hangul, unicode.Han, unicode.Hiragana, unicode.Katakana) { return 2 } switch { case r >= 0x1100 && r <= 0x115f: return 2 case r >= 0x2329 && r <= 0x232a: return 2 case r >= 0x2e80 && r <= 0xa4cf: return 2 case r >= 0xac00 && r <= 0xd7a3: return 2 case r >= 0xf900 && r <= 0xfaff: return 2 case r >= 0xfe10 && r <= 0xfe19: return 2 case r >= 0xfe30 && r <= 0xfe6f: return 2 case r >= 0xff00 && r <= 0xff60: return 2 case r >= 0xffe0 && r <= 0xffe6: return 2 default: return 1 } } func (s *screen) scrollUp() { for i := 0; i < s.rows-1; i++ { s.grid[i] = s.grid[i+1] } blank := make([]rune, s.cols) for j := range blank { blank[j] = ' ' } s.grid[s.rows-1] = blank } func (s *screen) handleCSI(priv byte, params string, cmd rune) { if priv != 0 { return } parts := strings.Split(params, ";") p1 := csiInt(parts, 0, 0) switch cmd { case 'H', 'f': row := csiInt(parts, 0, 1) col := csiInt(parts, 1, 1) s.cy = row - 1 s.cx = col - 1 s.clampCursor() case 'A': s.cy -= csiInt(parts, 0, 1) s.clampCursor() case 'B': s.cy += csiInt(parts, 0, 1) s.clampCursor() case 'C': s.cx += csiInt(parts, 0, 1) s.clampCursor() case 'D': s.cx -= csiInt(parts, 0, 1) s.clampCursor() case 'G': s.cx = csiInt(parts, 0, 1) - 1 s.clampCursor() case 'J': s.eraseDisplay(p1) case 'K': s.eraseLine(p1) } } func csiInt(parts []string, idx, def int) int { if idx >= len(parts) || parts[idx] == "" { return def } n, err := strconv.Atoi(parts[idx]) if err != nil { return def } return n } func (s *screen) eraseDisplay(mode int) { blankRow := func(i int) { for j := range s.grid[i] { s.grid[i][j] = ' ' } } switch mode { case 0: for j := s.cx; j < s.cols; j++ { s.clearCell(s.cy, j) } for i := s.cy + 1; i < s.rows; i++ { blankRow(i) } case 1: for i := 0; i < s.cy; i++ { blankRow(i) } for j := 0; j <= s.cx && j < s.cols; j++ { s.clearCell(s.cy, j) } case 2, 3: for i := 0; i < s.rows; i++ { blankRow(i) } } } func (s *screen) eraseLine(mode int) { switch mode { case 0: for j := s.cx; j < s.cols; j++ { s.clearCell(s.cy, j) } case 1: for j := 0; j <= s.cx && j < s.cols; j++ { s.clearCell(s.cy, j) } case 2: for j := range s.grid[s.cy] { s.grid[s.cy][j] = ' ' } } } func (s *screen) render() string { var b strings.Builder for i := range s.grid { end := len(s.grid[i]) for end > 0 && (s.grid[i][end-1] == ' ' || s.grid[i][end-1] == wideContinuation) { end-- } for _, r := range s.grid[i][:end] { if r == wideContinuation { continue } b.WriteRune(r) } b.WriteByte('\n') } return b.String() } // cleanANSI removes ANSI escape codes from the string. func cleanANSI(str string) string { // Remove OSC sequences first (before CSI so we don't split them) str = regexp.MustCompile(`\x1b\][^\x07]*\x07`).ReplaceAllString(str, "") str = regexp.MustCompile(`\x1b\][^\x1b]*\x1b\\`).ReplaceAllString(str, "") // Replace cursor-forward escapes with the exact count of spaces str = regexp.MustCompile(`\x1b\[(\d+)C`).ReplaceAllStringFunc(str, func(m string) string { var count int fmt.Sscanf(m, "\x1b[%dC", &count) if count > 100 { count = 100 } return strings.Repeat(" ", count) }) // Remove standard SGR (Select Graphic Rendition) codes e.g., ESC[31m, ESC[0m str = regexp.MustCompile(`\x1b\[[0-9;]*m`).ReplaceAllString(str, "") // Remove other CSI sequences str = regexp.MustCompile(`\x1b\[[?0-9;]*[a-zA-Z]`).ReplaceAllString(str, "") // Normalize carriage returns str = strings.ReplaceAll(str, "\r\n", "\n") str = strings.ReplaceAll(str, "\r", "\n") // Replace remaining C0 control chars (except \n and \t) with spaces controlRe := regexp.MustCompile(`[\x00-\x08\x0b\x0c\x0e-\x1f]`) str = controlRe.ReplaceAllString(str, " ") return str }