368 lines
7.5 KiB
Go
368 lines
7.5 KiB
Go
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
|
||
}
|