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:
parent
1479949db0
commit
42e0810a26
8 changed files with 714 additions and 27 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
278
apps/node/internal/adapters/cli/persistent_output_filter.go
Normal file
278
apps/node/internal/adapters/cli/persistent_output_filter.go
Normal 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, "")
|
||||
}
|
||||
121
apps/node/internal/adapters/cli/persistent_output_filter_test.go
Normal file
121
apps/node/internal/adapters/cli/persistent_output_filter_test.go
Normal 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}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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" +
|
||||
|
|
|
|||
Loading…
Reference in a new issue