diff --git a/apps/edge/internal/opsconsole/events.go b/apps/edge/internal/opsconsole/events.go index b09b9aa..2dc9446 100644 --- a/apps/edge/internal/opsconsole/events.go +++ b/apps/edge/internal/opsconsole/events.go @@ -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() { diff --git a/apps/edge/internal/opsconsole/events_test.go b/apps/edge/internal/opsconsole/events_test.go index 664d420..13b197f 100644 --- a/apps/edge/internal/opsconsole/events_test.go +++ b/apps/edge/internal/opsconsole/events_test.go @@ -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() diff --git a/apps/node/internal/adapters/cli/persistent.go b/apps/node/internal/adapters/cli/persistent.go index b034da5..490b86e 100644 --- a/apps/node/internal/adapters/cli/persistent.go +++ b/apps/node/internal/adapters/cli/persistent.go @@ -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) } diff --git a/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go b/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go index 7565ca9..36e7be1 100644 --- a/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go +++ b/apps/node/internal/adapters/cli/persistent_execute_blackbox_test.go @@ -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) { diff --git a/apps/node/internal/adapters/cli/persistent_output_filter.go b/apps/node/internal/adapters/cli/persistent_output_filter.go new file mode 100644 index 0000000..7156d10 --- /dev/null +++ b/apps/node/internal/adapters/cli/persistent_output_filter.go @@ -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, "") +} diff --git a/apps/node/internal/adapters/cli/persistent_output_filter_test.go b/apps/node/internal/adapters/cli/persistent_output_filter_test.go new file mode 100644 index 0000000..0641c2a --- /dev/null +++ b/apps/node/internal/adapters/cli/persistent_output_filter_test.go @@ -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} +} diff --git a/apps/node/internal/adapters/cli/status/parser.go b/apps/node/internal/adapters/cli/status/parser.go index d307f87..d721f1b 100644 --- a/apps/node/internal/adapters/cli/status/parser.go +++ b/apps/node/internal/adapters/cli/status/parser.go @@ -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() diff --git a/apps/node/internal/adapters/cli/status/parser_test.go b/apps/node/internal/adapters/cli/status/parser_test.go index 34a7f92..25dd6a6 100644 --- a/apps/node/internal/adapters/cli/status/parser_test.go +++ b/apps/node/internal/adapters/cli/status/parser_test.go @@ -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" +