필드 테스트를 위해 edge OpenAI-compatible 경로와 node adapter 설정을 확장하고, Jenkins 바이너리 빌드 및 control-plane/web compose 배포 구성을 함께 정리한다.
735 lines
19 KiB
Go
735 lines
19 KiB
Go
package status
|
||
|
||
import (
|
||
"fmt"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"unicode"
|
||
)
|
||
|
||
var (
|
||
dailyLimitRegex = regexp.MustCompile(`5h limit:.*?\]\s*([^%\n]+%)[^\(]*\(resets\s+([^\)]+)\)`)
|
||
weeklyLimitRegex = regexp.MustCompile(`Weekly limit:.*?\]\s*([^%\n]+%)[^\(]*\(resets\s+([^\)]+)\)`)
|
||
|
||
// Section header locators — they only locate the start of each block; they
|
||
// do NOT span forward into other blocks. This prevents the previous lazy
|
||
// `.*?` regex from accidentally pairing a session header with the week's
|
||
// "X% used / Resets Y" payload when cursor positioning in the raw PTY
|
||
// stream interleaves the two sections out of order.
|
||
claudeSessionHeaderRegex = regexp.MustCompile(`(?i)Current\s+session\b`)
|
||
claudeWeekHeaderRegex = regexp.MustCompile(`(?i)Current\s+week\b(?:\s*\(all\s+models\))?`)
|
||
|
||
// Payload extractor used *within* a single section slice.
|
||
claudePayloadRegex = regexp.MustCompile(`(?is)(\d+(?:\.\d+)?)%\s+used.*?Resets\s+([^\n]+)`)
|
||
|
||
geminiUsedQuotaRegex = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)%\s+used\s+\(Limit resets in ([^)]+)\)`)
|
||
geminiSimpleQuotaRegex = regexp.MustCompile(`(?is)\bquota\s+(\d+(?:\.\d+)?)%\s+used\b`)
|
||
geminiLimitReachedRegex = regexp.MustCompile(`(?i)Limit reached,\s*resets in ([^\n]+)`)
|
||
geminiUsageLimitRegex = regexp.MustCompile(`(?i)Usage limit:\s*([0-9][0-9,]*)`)
|
||
geminiModelLineRegex = regexp.MustCompile(`(?i)^(.*)\s+(\d+(?:\.\d+)?)%\s+Resets:\s*(.*)$`)
|
||
)
|
||
|
||
// ParseStatusOutput attempts to parse Claude or Codex usage limits from a raw
|
||
// PTY stream. Claude's /usage TUI uses cursor positioning and erase sequences
|
||
// to repaint the same panel multiple times, so the stream-append order can
|
||
// differ from what is actually visible on screen. We render the raw bytes
|
||
// through a terminal screen model first (renderVisibleScreen) and try Claude
|
||
// parsing against that; if it fails or only partially succeeds we fall back to
|
||
// the line-oriented cleanANSI which handles the simpler Codex output and
|
||
// non-cursor-repainted Claude streams.
|
||
func ParseStatusOutput(raw string) (*UsageStatus, error) {
|
||
visible := renderVisibleScreen(raw, 60, 200)
|
||
cleanRaw := cleanANSI(raw)
|
||
|
||
// Prefer whichever input yields a more complete Claude parse: a full
|
||
// session+week match beats a partial one, which beats nothing.
|
||
visStatus := &UsageStatus{RawOutput: visible}
|
||
rawStatus := &UsageStatus{RawOutput: cleanRaw}
|
||
visStatus.parseClaudeUsage(visible)
|
||
rawStatus.parseClaudeUsage(cleanRaw)
|
||
|
||
if pick := pickMoreComplete(visStatus, rawStatus); pick != nil {
|
||
return pick, nil
|
||
}
|
||
if rawStatus.parseClaudeStatus(cleanRaw) || rawStatus.parseClaudeStatus(visible) {
|
||
return rawStatus, nil
|
||
}
|
||
|
||
// Neither input produced Claude data — try Gemini, then Codex.
|
||
parsedGemini := rawStatus.parseGeminiQuota(cleanRaw)
|
||
if !rawStatus.parseGeminiModelUsage(cleanRaw) {
|
||
rawStatus.parseGeminiModelUsage(visible)
|
||
}
|
||
if parsedGemini || rawStatus.Metadata["model_usage_count"] != "" {
|
||
return rawStatus, nil
|
||
}
|
||
rawStatus.parseCodexUsage(cleanRaw)
|
||
return rawStatus, nil
|
||
}
|
||
|
||
// parseGeminiQuota parses Gemini `/stats model` daily quota output.
|
||
// `0% used (Limit resets in 24h)` and `Usage limit: 200` populate
|
||
// DailyLimit (remaining percent), DailyResetTime, and metadata. Real
|
||
// screen-reader output may also expose the header as `quota` followed by
|
||
// `0% used` without reset or limit details; in that case only remaining and
|
||
// used percentages are populated.
|
||
// `Limit reached, resets in 5h` is treated as 100% used.
|
||
func (s *UsageStatus) parseGeminiQuota(cleanRaw string) bool {
|
||
used := ""
|
||
reset := ""
|
||
if m := geminiUsedQuotaRegex.FindStringSubmatch(cleanRaw); len(m) >= 3 {
|
||
used = m[1] + "%"
|
||
reset = strings.TrimSpace(m[2])
|
||
} else if m := geminiLimitReachedRegex.FindStringSubmatch(cleanRaw); len(m) >= 2 {
|
||
used = "100%"
|
||
reset = strings.TrimSpace(m[1])
|
||
} else if m := geminiSimpleQuotaRegex.FindStringSubmatch(cleanRaw); len(m) >= 2 {
|
||
used = m[1] + "%"
|
||
}
|
||
|
||
limitNumeric := ""
|
||
if m := geminiUsageLimitRegex.FindStringSubmatch(cleanRaw); len(m) >= 2 {
|
||
limitNumeric = strings.TrimSpace(m[1])
|
||
}
|
||
|
||
if used == "" {
|
||
return false
|
||
}
|
||
|
||
if s.Metadata == nil {
|
||
s.Metadata = make(map[string]string)
|
||
}
|
||
s.Metadata["daily_label"] = "Daily quota"
|
||
s.DailyLimit = remainingPercent(used)
|
||
s.DailyResetTime = reset
|
||
s.Metadata["used_percent"] = used
|
||
if limitNumeric != "" {
|
||
s.Metadata["usage_limit"] = limitNumeric
|
||
}
|
||
return true
|
||
}
|
||
|
||
// parseGeminiModelUsage parses the `/model` screen-reader "Model usage" area,
|
||
// storing per-model used percentages and reset times in metadata.
|
||
func (s *UsageStatus) parseGeminiModelUsage(cleanRaw string) bool {
|
||
lower := strings.ToLower(cleanRaw)
|
||
start := strings.LastIndex(lower, "model usage")
|
||
if start < 0 {
|
||
return false
|
||
}
|
||
block := cleanRaw[start+len("model usage"):]
|
||
blockLower := strings.ToLower(block)
|
||
if end := strings.Index(blockLower, "(press esc"); end >= 0 {
|
||
block = block[:end]
|
||
}
|
||
|
||
type modelUsage struct {
|
||
name string
|
||
usedPercent string
|
||
reset string
|
||
}
|
||
|
||
var models []modelUsage
|
||
modelIndex := make(map[string]int)
|
||
lines := strings.Split(block, "\n")
|
||
for i := 0; i < len(lines); i++ {
|
||
line := strings.TrimSpace(lines[i])
|
||
if line == "" || !strings.Contains(line, "%") || !strings.Contains(strings.ToLower(line), "resets:") {
|
||
continue
|
||
}
|
||
|
||
m := geminiModelLineRegex.FindStringSubmatch(line)
|
||
if len(m) < 4 {
|
||
continue
|
||
}
|
||
|
||
name := geminiModelNameFromUsagePrefix(m[1])
|
||
if name == "" {
|
||
continue
|
||
}
|
||
used := strings.TrimSpace(m[2]) + "%"
|
||
reset := strings.TrimSpace(m[3])
|
||
if !strings.Contains(reset, ")") && i+1 < len(lines) {
|
||
next := strings.TrimSpace(lines[i+1])
|
||
if strings.HasPrefix(next, "(") {
|
||
reset = strings.TrimSpace(reset + " " + next)
|
||
i++
|
||
}
|
||
}
|
||
|
||
entry := modelUsage{name: name, usedPercent: used, reset: reset}
|
||
if idx, ok := modelIndex[name]; ok {
|
||
models[idx] = entry
|
||
continue
|
||
}
|
||
modelIndex[name] = len(models)
|
||
models = append(models, entry)
|
||
}
|
||
|
||
if len(models) == 0 {
|
||
return false
|
||
}
|
||
|
||
if s.Metadata == nil {
|
||
s.Metadata = make(map[string]string)
|
||
}
|
||
s.Metadata["model_usage_count"] = strconv.Itoa(len(models))
|
||
for i, model := range models {
|
||
prefix := fmt.Sprintf("model_usage_%d", i)
|
||
s.Metadata[prefix+"_name"] = model.name
|
||
s.Metadata[prefix+"_used_percent"] = model.usedPercent
|
||
if model.reset != "" {
|
||
s.Metadata[prefix+"_reset"] = model.reset
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func geminiModelNameFromUsagePrefix(prefix string) string {
|
||
prefix = strings.TrimSpace(prefix)
|
||
if prefix == "" {
|
||
return ""
|
||
}
|
||
if idx := strings.IndexAny(prefix, "▬━─█░#▉▊▋▌▍▎▏■"); idx >= 0 {
|
||
return strings.TrimSpace(prefix[:idx])
|
||
}
|
||
return prefix
|
||
}
|
||
|
||
// pickMoreComplete returns the status with more fields populated, or nil if
|
||
// neither has any Claude limit data.
|
||
func pickMoreComplete(a, b *UsageStatus) *UsageStatus {
|
||
score := func(s *UsageStatus) int {
|
||
n := 0
|
||
if s.DailyLimit != "" {
|
||
n++
|
||
}
|
||
if s.WeeklyLimit != "" {
|
||
n++
|
||
}
|
||
if s.DailyResetTime != "" {
|
||
n++
|
||
}
|
||
if s.WeeklyResetTime != "" {
|
||
n++
|
||
}
|
||
return n
|
||
}
|
||
sa, sb := score(a), score(b)
|
||
if sa == 0 && sb == 0 {
|
||
return nil
|
||
}
|
||
if sa >= sb {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
// parseClaudeUsage attempts to parse Claude /usage output format.
|
||
//
|
||
// We locate each section header (Current session / Current week) and extract
|
||
// the "X% used … Resets Y" payload that immediately follows it, stopping at
|
||
// the next opposing header so the session header is never paired with the
|
||
// week's payload. Each header may appear multiple times in the raw stream
|
||
// (initial loading repaint then data repaint); we walk the occurrences from
|
||
// last to first and take the first one whose section contains a payload, so
|
||
// the most recent render wins.
|
||
func (s *UsageStatus) parseClaudeUsage(cleanRaw string) bool {
|
||
sessionLocs := claudeSessionHeaderRegex.FindAllStringIndex(cleanRaw, -1)
|
||
weekLocs := claudeWeekHeaderRegex.FindAllStringIndex(cleanRaw, -1)
|
||
|
||
extract := func(headerLocs [][]int, oppositeLocs [][]int) (string, string, bool) {
|
||
for i := len(headerLocs) - 1; i >= 0; i-- {
|
||
start := headerLocs[i][1]
|
||
end := len(cleanRaw)
|
||
for _, opp := range oppositeLocs {
|
||
if opp[0] > start && opp[0] < end {
|
||
end = opp[0]
|
||
}
|
||
}
|
||
if end <= start {
|
||
continue
|
||
}
|
||
section := cleanRaw[start:end]
|
||
if m := claudePayloadRegex.FindStringSubmatch(section); len(m) >= 3 {
|
||
return m[1], strings.TrimSpace(m[2]), true
|
||
}
|
||
}
|
||
return "", "", false
|
||
}
|
||
|
||
if used, reset, ok := extract(sessionLocs, weekLocs); ok {
|
||
if s.Metadata == nil {
|
||
s.Metadata = make(map[string]string)
|
||
}
|
||
s.Metadata["daily_label"] = "Current session"
|
||
s.DailyLimit = remainingPercent(used)
|
||
s.DailyResetTime = reset
|
||
}
|
||
if used, reset, ok := extract(weekLocs, sessionLocs); ok {
|
||
if s.Metadata == nil {
|
||
s.Metadata = make(map[string]string)
|
||
}
|
||
s.Metadata["weekly_label"] = "Current week"
|
||
s.WeeklyLimit = remainingPercent(used)
|
||
s.WeeklyResetTime = reset
|
||
}
|
||
return s.DailyLimit != "" || s.WeeklyLimit != ""
|
||
}
|
||
|
||
func (s *UsageStatus) parseClaudeStatus(cleanRaw string) bool {
|
||
lower := strings.ToLower(cleanRaw)
|
||
if !strings.Contains(lower, "claudecode") && !strings.Contains(lower, "claude code") {
|
||
return false
|
||
}
|
||
|
||
fields := map[string]string{
|
||
"version": "claude_status_version",
|
||
"sessionname": "claude_status_session_name",
|
||
"sessionid": "claude_status_session_id",
|
||
"cwd": "claude_status_cwd",
|
||
"loginmethod": "claude_status_login_method",
|
||
"organization": "claude_status_organization",
|
||
"email": "claude_status_email",
|
||
"model": "claude_status_model",
|
||
"mcpservers": "claude_status_mcp_servers",
|
||
}
|
||
|
||
found := 0
|
||
for _, line := range strings.Split(cleanRaw, "\n") {
|
||
key, value, ok := strings.Cut(line, ":")
|
||
if !ok {
|
||
continue
|
||
}
|
||
normalized := normalizeClaudeStatusKey(key)
|
||
metadataKey, ok := fields[normalized]
|
||
if !ok {
|
||
continue
|
||
}
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
continue
|
||
}
|
||
if s.Metadata == nil {
|
||
s.Metadata = make(map[string]string)
|
||
}
|
||
s.Metadata[metadataKey] = value
|
||
found++
|
||
}
|
||
if found == 0 {
|
||
return false
|
||
}
|
||
s.Metadata["status_kind"] = "claude"
|
||
return true
|
||
}
|
||
|
||
func normalizeClaudeStatusKey(key string) string {
|
||
var b strings.Builder
|
||
for _, r := range strings.ToLower(key) {
|
||
if unicode.IsLetter(r) {
|
||
b.WriteRune(r)
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// parseCodexUsage attempts to parse Codex output format and sets metadata labels.
|
||
func (s *UsageStatus) parseCodexUsage(cleanRaw string) {
|
||
if match := dailyLimitRegex.FindStringSubmatch(cleanRaw); len(match) > 2 {
|
||
s.DailyLimit = strings.TrimSpace(match[1])
|
||
s.DailyResetTime = strings.TrimSpace(match[2])
|
||
}
|
||
if match := weeklyLimitRegex.FindStringSubmatch(cleanRaw); len(match) > 2 {
|
||
s.WeeklyLimit = strings.TrimSpace(match[1])
|
||
s.WeeklyResetTime = strings.TrimSpace(match[2])
|
||
}
|
||
if s.DailyLimit != "" || s.WeeklyLimit != "" {
|
||
if s.Metadata == nil {
|
||
s.Metadata = make(map[string]string)
|
||
}
|
||
if s.DailyLimit != "" {
|
||
s.Metadata["daily_label"] = "5h limit"
|
||
}
|
||
if s.WeeklyLimit != "" {
|
||
s.Metadata["weekly_label"] = "Weekly limit"
|
||
}
|
||
}
|
||
}
|
||
|
||
// remainingPercent converts a "used" percentage string to remaining percentage.
|
||
func remainingPercent(usedStr string) string {
|
||
usedStr = strings.TrimSuffix(usedStr, "%")
|
||
usedStr = strings.TrimSpace(usedStr)
|
||
usedVal, err := strconv.ParseFloat(usedStr, 64)
|
||
if err != nil {
|
||
return usedStr
|
||
}
|
||
remaining := 100 - usedVal
|
||
if remaining < 0 {
|
||
remaining = 0
|
||
}
|
||
if usedVal == float64(int(usedVal)) {
|
||
return fmt.Sprintf("%d%%", int(remaining))
|
||
}
|
||
return fmt.Sprintf("%.1f%%", remaining)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
const wideContinuation rune = -1
|
||
|
||
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
|
||
}
|