feat: add CLI persistent output filter for Claude TUI and improve status parser

- Add persistentOutputFilter interface with passthrough and Claude TUI filters
- Implement claudeTUIOutputFilter that renders visible screen and extracts assistant messages
- Add output filter integration to persistent adapter
- Improve status parser with additional test coverage
- Update edge opsconsole event handling with test coverage
This commit is contained in:
toki 2026-05-19 07:12:09 +09:00
parent 1479949db0
commit 42e0810a26
8 changed files with 714 additions and 27 deletions

View file

@ -223,12 +223,21 @@ func (s *ResponseStream) Write(delta string) {
if s.out == nil || delta == "" {
return
}
if !s.started {
fmt.Fprint(s.out, s.prefix)
s.started = true
for delta != "" {
if !s.started || s.endedWithNewline {
fmt.Fprint(s.out, s.prefix)
s.started = true
s.endedWithNewline = false
}
idx := strings.IndexByte(delta, '\n')
if idx < 0 {
fmt.Fprint(s.out, delta)
return
}
fmt.Fprint(s.out, delta[:idx+1])
s.endedWithNewline = true
delta = delta[idx+1:]
}
fmt.Fprint(s.out, delta)
s.endedWithNewline = strings.HasSuffix(delta, "\n")
}
func (s *ResponseStream) Finish() {

View file

@ -79,6 +79,19 @@ func TestResponseStreamWritesBeforeFinish(t *testing.T) {
}
}
func TestResponseStreamPrefixesEachMessageLine(t *testing.T) {
var out bytes.Buffer
stream := NewResponseStream(&out, "[node-test-message] ")
stream.Write("line one\nline two\nline three")
stream.Finish()
want := "[node-test-message] line one\n[node-test-message] line two\n[node-test-message] line three\n"
if got := out.String(); got != want {
t.Fatalf("unexpected multiline output:\nwant: %q\n got: %q", want, got)
}
}
func TestEventRouterPrintsNodeScopedAsyncRun(t *testing.T) {
var out bytes.Buffer
reg := edgenode.NewRegistry()

View file

@ -18,7 +18,11 @@ import (
"iop/packages/config"
)
const terminalInputDelay = 2 * time.Millisecond
const (
terminalInputDelay = 2 * time.Millisecond
terminalRows = 80
terminalCols = 240
)
func emitRuntimeError(ctx context.Context, sink runtime.EventSink, runID, msg string) error {
_ = sink.Emit(ctx, runtime.RuntimeEvent{
@ -77,6 +81,9 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec,
if idleTimeout <= 0 {
idleTimeout = 1500 * time.Millisecond
}
targetName := cliTargetName(spec)
outputFilter := newPersistentOutputFilter(targetName, profile, prompt)
waitForFilteredMessage := profile.Terminal && isClaudeTerminalProfile(targetName, profile)
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
@ -120,13 +127,15 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec,
c.mu.Unlock()
return emitRuntimeError(ctx, sink, spec.RunID, "persistent session process exited unexpectedly")
}
outputTokens += len(strings.Fields(out.text))
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeDelta,
Delta: out.text,
Timestamp: time.Now(),
})
if delta := outputFilter.Filter(out.text); delta != "" {
outputTokens += len(strings.Fields(delta))
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeDelta,
Delta: delta,
Timestamp: time.Now(),
})
}
markerLines := []string(nil)
if out.markerLine != "" {
markerLines = append(markerLines, out.markerLine)
@ -134,6 +143,15 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec,
markerLines = consumeCompleteLines(&markerBuf, out.text)
}
if matcher.matchAny(markerLines) {
if delta := outputFilter.Flush(); delta != "" {
outputTokens += len(strings.Fields(delta))
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeDelta,
Delta: delta,
Timestamp: time.Now(),
})
}
return sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeComplete,
@ -158,6 +176,18 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec,
idleTimer.Reset(idleTimeout)
}
case <-idleC:
if delta := outputFilter.Flush(); delta != "" {
outputTokens += len(strings.Fields(delta))
_ = sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeDelta,
Delta: delta,
Timestamp: time.Now(),
})
} else if waitForFilteredMessage {
idleTimer.Reset(idleTimeout)
continue
}
return sink.Emit(ctx, runtime.RuntimeEvent{
RunID: spec.RunID,
Type: runtime.EventTypeComplete,
@ -284,7 +314,10 @@ func startProfileSession(_ context.Context, key sessionKey, profile config.CLIPr
if len(profile.Env) > 0 {
cmd.Env = append(cmd.Environ(), profile.Env...)
}
ptmx, err := pty.Start(cmd)
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
Rows: terminalRows,
Cols: terminalCols,
})
if err != nil {
return nil, fmt.Errorf("pty start: %w", err)
}

View file

@ -385,6 +385,138 @@ func TestCLIStartPersistentTerminalEmitsRawChunksWithoutNewline(t *testing.T) {
}
}
func TestCLIStartPersistentTerminalUsesWidePTY(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"size-terminal": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do stty size; done`},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 100,
StartupIdleTimeoutMS: 50,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
ctx := context.Background()
if err := c.Start(ctx); err != nil {
t.Fatalf("start: %v", err)
}
defer func() { _ = c.Stop(ctx) }()
runCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
sink := &testutil.FakeSink{}
err := c.Execute(runCtx, noderuntime.ExecutionSpec{
RunID: "run-size",
Target: "size-terminal",
Input: map[string]any{"prompt": "hello"},
}, sink)
if err != nil {
t.Fatalf("execute: %v", err)
}
if combined := testutil.CollectDeltas(sink.Events()); !strings.Contains(combined, "80 240") {
t.Fatalf("expected wide PTY size in deltas, got %q", combined)
}
}
func TestCLIExecutePersistentClaudeTUIFiltersTerminalChrome(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"claude-tui": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf '\033[2C\r\033[7A\342\227\217\033[1Cmessage:%s\r\n\302\267 Cogitating... (1s, 1 tokens)\r\n\342\235\257 ' "$line"; done`},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 100,
StartupIdleTimeoutMS: 50,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
ctx := context.Background()
if err := c.Start(ctx); err != nil {
t.Fatalf("start: %v", err)
}
defer func() { _ = c.Stop(ctx) }()
runCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
sink := &testutil.FakeSink{}
err := c.Execute(runCtx, noderuntime.ExecutionSpec{
RunID: "run-filter",
Target: "claude-tui",
Input: map[string]any{"prompt": "hello"},
}, sink)
if err != nil {
t.Fatalf("execute: %v", err)
}
combined := testutil.CollectDeltas(sink.Events())
if combined != "message:hello\n" {
t.Fatalf("expected only assistant message delta, got %q", combined)
}
}
func TestCLIExecutePersistentClaudeTUIDoesNotCompleteOnStatusOnlyIdle(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"claude-tui": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf '3\r\n\r\nThundering\342\200\2466\r\n'; done`},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 60,
StartupIdleTimeoutMS: 50,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
ctx := context.Background()
if err := c.Start(ctx); err != nil {
t.Fatalf("start: %v", err)
}
defer func() { _ = c.Stop(ctx) }()
runCtx, cancel := context.WithTimeout(ctx, 220*time.Millisecond)
defer cancel()
sink := &testutil.FakeSink{}
err := c.Execute(runCtx, noderuntime.ExecutionSpec{
RunID: "run-status-only",
Target: "claude-tui",
Input: map[string]any{"prompt": "hello"},
}, sink)
if err == nil {
t.Fatal("execute completed successfully before any assistant message")
}
for _, event := range sink.Events() {
if event.Type == noderuntime.EventTypeDelta {
t.Fatalf("status-only output should not be emitted as delta: %q", event.Delta)
}
if event.Type == noderuntime.EventTypeComplete {
t.Fatalf("status-only output should not emit complete: %+v", event)
}
}
}
// TestCLIExecutePersistentCancelDoesNotTerminateSession verifies that a
// cancelled run leaves the session alive so subsequent runs can reuse it.
func TestCLIExecutePersistentCancelDoesNotTerminateSession(t *testing.T) {

View file

@ -0,0 +1,278 @@
package cli
import (
"path/filepath"
"regexp"
"strings"
"unicode"
"iop/apps/node/internal/adapters/cli/status"
"iop/packages/config"
)
type persistentOutputFilter interface {
Filter(chunk string) string
Flush() string
}
type passthroughOutputFilter struct{}
func (passthroughOutputFilter) Filter(chunk string) string {
return chunk
}
func (passthroughOutputFilter) Flush() string {
return ""
}
func newPersistentOutputFilter(target string, profile config.CLIProfileConf, prompt string) persistentOutputFilter {
if profile.Terminal && isClaudeTerminalProfile(target, profile) {
return newClaudeTUIOutputFilter(prompt)
}
return passthroughOutputFilter{}
}
func isClaudeTerminalProfile(target string, profile config.CLIProfileConf) bool {
if strings.Contains(strings.ToLower(target), "claude-tui") {
return true
}
return strings.EqualFold(filepath.Base(profile.Command), "claude")
}
type claudeTUIOutputFilter struct {
raw strings.Builder
prompt string
}
func newClaudeTUIOutputFilter(prompt string) *claudeTUIOutputFilter {
return &claudeTUIOutputFilter{prompt: strings.TrimSpace(prompt)}
}
func (f *claudeTUIOutputFilter) Filter(chunk string) string {
appendBounded(&f.raw, chunk, 256*1024)
return ""
}
func (f *claudeTUIOutputFilter) Flush() string {
screen := status.RenderVisibleScreen(f.raw.String(), terminalRows, terminalCols)
message := latestClaudeAssistantMessage(screen)
if message == "" {
message = fallbackClaudeVisibleMessage(screen, f.prompt)
}
if message == "" {
message = fallbackClaudeVisibleMessage(cleanClaudeTerminalOutput(f.raw.String()), f.prompt)
}
if message == "" {
return ""
}
return message + "\n"
}
func latestClaudeAssistantMessage(screen string) string {
lines := strings.Split(screen, "\n")
start := -1
for i, line := range lines {
if _, ok := claudeAssistantLine(line); ok {
start = i
}
}
if start < 0 {
return ""
}
out := make([]string, 0, len(lines)-start)
if text, ok := claudeAssistantLine(lines[start]); ok {
appendClaudeMessageLine(&out, text)
}
for _, line := range lines[start+1:] {
line = strings.TrimSpace(line)
if line == "" {
if len(out) > 0 {
out = append(out, "")
}
continue
}
if isClaudeTUIBoundaryLine(line) {
break
}
appendClaudeMessageLine(&out, line)
}
return strings.Join(trimTrailingEmptyLines(out), "\n")
}
func appendClaudeMessageLine(out *[]string, line string) {
line = strings.TrimSpace(line)
if line == "" || isClaudeTUIBoundaryLine(line) {
return
}
*out = append(*out, line)
}
func claudeAssistantLine(line string) (string, bool) {
const marker = "\u25cf"
idx := strings.Index(line, marker)
if idx < 0 {
return "", false
}
return strings.TrimSpace(line[idx+len(marker):]), true
}
func isClaudeTUIBoundaryLine(line string) bool {
line = strings.TrimSpace(line)
if line == "" {
return true
}
lower := strings.ToLower(line)
switch {
case strings.HasPrefix(line, ">"):
return true
case strings.HasPrefix(line, "\u276f"):
return true
case strings.HasPrefix(line, "\u2500"):
return true
case strings.HasPrefix(line, "\u2570") || strings.HasPrefix(line, "\u256d"):
return true
case strings.HasPrefix(line, "\u2736") || strings.HasPrefix(line, "\u273b") || strings.HasPrefix(line, "\u00b7"):
return true
case strings.HasPrefix(line, "\u23bf"):
return true
case strings.HasPrefix(line, "\u23f5"):
return true
case strings.HasPrefix(line, "\u25cf"):
return true
case strings.HasPrefix(lower, "tip:"):
return true
case strings.Contains(lower, "tokens)"):
return true
case isClaudeTransientStatusLine(line):
return true
case strings.Contains(lower, "press shift+tab"):
return true
case strings.Contains(lower, "shift+tab to cycle"):
return true
case strings.Contains(lower, "bypass permissions on"):
return true
case strings.Contains(lower, "for agents"):
return true
case strings.Contains(lower, "esc to interrupt"):
return true
case strings.Contains(lower, "bypasspermissions"):
return true
}
return false
}
func fallbackClaudeVisibleMessage(text, prompt string) string {
lines := strings.Split(text, "\n")
var block []string
var lastBlock []string
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
if len(block) > 0 {
block = append(block, "")
}
continue
}
if !isClaudeMessageCandidate(line, prompt) {
if len(block) > 0 {
lastBlock = append(lastBlock[:0], trimTrailingEmptyLines(block)...)
block = nil
}
continue
}
block = append(block, line)
}
if len(block) == 0 {
block = lastBlock
}
return strings.Join(trimTrailingEmptyLines(block), "\n")
}
func trimTrailingEmptyLines(lines []string) []string {
for len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return lines
}
func isClaudeMessageCandidate(line, prompt string) bool {
if line == "" || isClaudeTUIBoundaryLine(line) {
return false
}
if prompt != "" && strings.Contains(line, prompt) {
return false
}
if isNumericOnlyLine(line) {
return false
}
lower := strings.ToLower(line)
switch {
case strings.HasPrefix(lower, "claude code"):
return false
case strings.HasPrefix(lower, "welcome to claude"):
return false
case strings.HasPrefix(lower, "cwd:"):
return false
case strings.HasPrefix(lower, "model:"):
return false
case strings.HasPrefix(lower, "session:"):
return false
case strings.HasPrefix(lower, "permissions:"):
return false
case strings.HasPrefix(lower, "warning:"):
return false
case strings.HasPrefix(lower, "yes, i accept"):
return false
case strings.HasPrefix(lower, "no, exit"):
return false
}
return true
}
func isClaudeTransientStatusLine(line string) bool {
lower := strings.ToLower(strings.TrimSpace(line))
for _, word := range []string{
"cogitating",
"crunched",
"pondering",
"processing",
"thinking",
"thundering",
"undulating",
"working",
} {
if strings.Contains(lower, word) {
return true
}
}
return false
}
func isNumericOnlyLine(line string) bool {
line = strings.TrimSpace(line)
if line == "" {
return false
}
for _, r := range line {
if !unicode.IsDigit(r) {
return false
}
}
return true
}
var (
claudeTerminalOSCRegex = regexp.MustCompile(`\x1b\][^\x07]*(?:\x07|\x1b\\)`)
claudeTerminalCSIRegex = regexp.MustCompile(`\x1b\[[?0-9;]*[ -/]*[@-~]`)
claudeTerminalControlRegex = regexp.MustCompile(`[\x00-\x08\x0b\x0c\x0e-\x1f]`)
)
func cleanClaudeTerminalOutput(text string) string {
text = claudeTerminalOSCRegex.ReplaceAllString(text, "")
text = claudeTerminalCSIRegex.ReplaceAllString(text, "")
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
text = strings.ReplaceAll(text, "\u00a0", " ")
return claudeTerminalControlRegex.ReplaceAllString(text, "")
}

View file

@ -0,0 +1,121 @@
package cli
import (
"strings"
"testing"
"iop/packages/config"
)
func TestClaudeTUIOutputFilterExtractsAssistantMessageOnly(t *testing.T) {
filter := newClaudeTUIOutputFilter("")
if got := filter.Filter("\x1b[2C\r\x1b[7A\u25cf\x1b[1CIOP_MESSAGE_ONLY\r\x1b[2B\u00b7 Cogitating... (2s · 1 tokens)\x1b[K\r\n\u276f "); got != "" {
t.Fatalf("Filter() = %q, want deferred output", got)
}
got := filter.Flush()
if got != "IOP_MESSAGE_ONLY\n" {
t.Fatalf("Flush() = %q, want %q", got, "IOP_MESSAGE_ONLY\n")
}
if strings.Contains(got, "\x1b") || strings.Contains(got, "Cogitating") || strings.Contains(got, "\u276f") {
t.Fatalf("filtered message still contains terminal chrome: %q", got)
}
}
func TestClaudeTUIOutputFilterSuppressesRepaintedMessage(t *testing.T) {
filter := newClaudeTUIOutputFilter("")
if got := filter.Filter("\u25cf IOP_MESSAGE_ONCE\r\n"); got != "" {
t.Fatalf("Filter() = %q, want deferred output", got)
}
if got := filter.Filter("\r\x1b[4A\u25cf IOP_MESSAGE_ONCE\r\n"); got != "" {
t.Fatalf("repainted Filter() = %q, want deferred output", got)
}
if got := filter.Flush(); got != "IOP_MESSAGE_ONCE\n" {
t.Fatalf("Flush() = %q, want %q", got, "IOP_MESSAGE_ONCE\n")
}
}
func TestClaudeTUIOutputFilterUsesLatestAssistantMessage(t *testing.T) {
filter := newClaudeTUIOutputFilter("")
filter.Filter("\u25cf IOP_FIRST_MESSAGE\r\n\u2500\r\n")
filter.Filter("\u25cf IOP_SECOND_MESSAGE\r\n\u276f ")
if got := filter.Flush(); got != "IOP_SECOND_MESSAGE\n" {
t.Fatalf("Flush() = %q, want %q", got, "IOP_SECOND_MESSAGE\n")
}
}
func TestPassthroughOutputFilterKeepsNonClaudeTerminalOutput(t *testing.T) {
filter := newPersistentOutputFilter("raw-terminal", testProfile("sh", true), "")
got := filter.Filter("\x1b[31mreply:hello\x1b[0m")
if got != "\x1b[31mreply:hello\x1b[0m" {
t.Fatalf("Filter() = %q", got)
}
}
func TestClaudeTUIOutputFilterFallsBackWhenAssistantMarkerIsAbsent(t *testing.T) {
filter := newClaudeTUIOutputFilter("hello")
filter.Filter("\r\x1b[2Khello\r\n")
filter.Filter("\r\x1b[2KIOP_MARKERLESS_REPLY\r\n")
filter.Filter("\u276f ")
if got := filter.Flush(); got != "IOP_MARKERLESS_REPLY\n" {
t.Fatalf("Flush() = %q, want %q", got, "IOP_MARKERLESS_REPLY\n")
}
}
func TestClaudeTUIOutputFilterKeepsMultilineAssistantMessage(t *testing.T) {
filter := newClaudeTUIOutputFilter("")
filter.Filter("\u25cf line one\r\nline two\r\n\r\nline four\r\n\r\n\u273b Crunched for 4s\r\n\u23f5\u23f5 bypass permissions on (shift+tab to cycle) \u00b7 \u2190 for agents\r\n\u276f ")
want := "line one\nline two\n\nline four\n"
if got := filter.Flush(); got != want {
t.Fatalf("Flush() = %q, want %q", got, want)
}
}
func TestClaudeTUIOutputFilterDropsFooterWithoutAssistantMarker(t *testing.T) {
filter := newClaudeTUIOutputFilter("hello")
filter.Filter("hello\r\n")
filter.Filter("real reply\r\n")
filter.Filter("\u23f5\u23f5 bypass permissions on (shift+tab to cycle) \u00b7 \u2190 for agents\r\n")
if got := filter.Flush(); got != "real reply\n" {
t.Fatalf("Flush() = %q, want %q", got, "real reply\n")
}
}
func TestClaudeTUIOutputFilterDropsTransientStatusWithoutAssistantMarker(t *testing.T) {
filter := newClaudeTUIOutputFilter("\uc548\ub155?")
filter.Filter("\uc548\ub155?\r\n")
filter.Filter("3\r\n\r\n\r\nThundering\u20266\r\n")
if got := filter.Flush(); got != "" {
t.Fatalf("Flush() = %q, want no message", got)
}
}
func TestClaudeTUIOutputFilterKeepsAssistantAfterTransientStatus(t *testing.T) {
filter := newClaudeTUIOutputFilter("\uc548\ub155?")
filter.Filter("\uc548\ub155?\r\n")
filter.Filter("3\r\n\r\nThundering\u20266\r\n")
filter.Filter("\u25cf \uc548\ub155\ud558\uc138\uc694.\r\n")
if got := filter.Flush(); got != "\uc548\ub155\ud558\uc138\uc694.\n" {
t.Fatalf("Flush() = %q, want %q", got, "\uc548\ub155\ud558\uc138\uc694.\n")
}
}
func testProfile(command string, terminal bool) config.CLIProfileConf {
return config.CLIProfileConf{Command: command, Terminal: terminal}
}

View file

@ -5,6 +5,7 @@ import (
"regexp"
"strconv"
"strings"
"unicode"
)
var (
@ -315,6 +316,12 @@ func remainingPercent(usedStr string) string {
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
@ -410,6 +417,8 @@ type screen struct {
cy, cx int
}
const wideContinuation rune = -1
func newScreen(rows, cols int) *screen {
g := make([][]rune, rows)
for i := range g {
@ -437,16 +446,80 @@ func (s *screen) clampCursor() {
}
func (s *screen) putRune(r rune) {
w := terminalRuneWidth(r)
if w <= 0 {
return
}
if s.cx >= s.cols {
s.cx = 0
s.cy++
if s.cy >= s.rows {
s.scrollUp()
s.cy = s.rows - 1
}
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
s.cx++
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() {
@ -515,7 +588,7 @@ func (s *screen) eraseDisplay(mode int) {
switch mode {
case 0:
for j := s.cx; j < s.cols; j++ {
s.grid[s.cy][j] = ' '
s.clearCell(s.cy, j)
}
for i := s.cy + 1; i < s.rows; i++ {
blankRow(i)
@ -525,7 +598,7 @@ func (s *screen) eraseDisplay(mode int) {
blankRow(i)
}
for j := 0; j <= s.cx && j < s.cols; j++ {
s.grid[s.cy][j] = ' '
s.clearCell(s.cy, j)
}
case 2, 3:
for i := 0; i < s.rows; i++ {
@ -538,11 +611,11 @@ func (s *screen) eraseLine(mode int) {
switch mode {
case 0:
for j := s.cx; j < s.cols; j++ {
s.grid[s.cy][j] = ' '
s.clearCell(s.cy, j)
}
case 1:
for j := 0; j <= s.cx && j < s.cols; j++ {
s.grid[s.cy][j] = ' '
s.clearCell(s.cy, j)
}
case 2:
for j := range s.grid[s.cy] {
@ -554,8 +627,16 @@ func (s *screen) eraseLine(mode int) {
func (s *screen) render() string {
var b strings.Builder
for i := range s.grid {
line := strings.TrimRight(string(s.grid[i]), " ")
b.WriteString(line)
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()

View file

@ -277,6 +277,26 @@ func TestParseStatusOutput_ClaudeUsageFromVisibleScreenRepaint(t *testing.T) {
}
}
func TestRenderVisibleScreenTreatsHangulAsWide(t *testing.T) {
const ESC = "\x1b"
got := RenderVisibleScreen("한글"+ESC+"[2DX", 1, 20)
want := "한X\n"
if got != want {
t.Fatalf("RenderVisibleScreen() = %q, want %q", got, want)
}
}
func TestRenderVisibleScreenClearsWideContinuation(t *testing.T) {
const ESC = "\x1b"
got := RenderVisibleScreen("한글"+ESC+"[2D"+ESC+"[K", 1, 20)
want := "한\n"
if got != want {
t.Fatalf("RenderVisibleScreen() = %q, want %q", got, want)
}
}
func TestParseStatusOutput_GeminiStatsQuota(t *testing.T) {
rawOutput := "Auto (Gemini 3) Stats For Nerds\n" +
"0% used (Limit resets in 24h)\n" +