iop/apps/node/internal/adapters/cli/status/screen.go

368 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}