diff --git a/apps/node/internal/adapters/cli/persistent.go b/apps/node/internal/adapters/cli/persistent.go index db48b40..807cc89 100644 --- a/apps/node/internal/adapters/cli/persistent.go +++ b/apps/node/internal/adapters/cli/persistent.go @@ -83,8 +83,12 @@ func (c *CLI) executePersistent(ctx context.Context, spec runtime.ExecutionSpec, idleTimeout = 1500 * time.Millisecond } targetName := cliTargetName(spec) - outputFilter := newPersistentOutputFilter(targetName, profile, prompt) waitForFilteredMessage := profile.Terminal && isClaudeTerminalProfile(targetName, profile) + baselineAssistant := "" + if waitForFilteredMessage { + baselineAssistant, _ = latestClaudeAssistantMessageFromCleanOutput(cleanClaudeTerminalOutput(sess.getTail())) + } + outputFilter := newPersistentOutputFilter(targetName, profile, prompt, baselineAssistant) _ = sink.Emit(ctx, runtime.RuntimeEvent{ RunID: spec.RunID, diff --git a/apps/node/internal/adapters/cli/persistent_output_filter.go b/apps/node/internal/adapters/cli/persistent_output_filter.go index d8ff01d..742debc 100644 --- a/apps/node/internal/adapters/cli/persistent_output_filter.go +++ b/apps/node/internal/adapters/cli/persistent_output_filter.go @@ -35,9 +35,9 @@ func (passthroughOutputFilter) CompletionMessage() string { return "" } -func newPersistentOutputFilter(target string, profile config.CLIProfileConf, prompt string) persistentOutputFilter { +func newPersistentOutputFilter(target string, profile config.CLIProfileConf, prompt string, baselineAssistant string) persistentOutputFilter { if profile.Terminal && isClaudeTerminalProfile(target, profile) { - return newClaudeTUIOutputFilter(prompt) + return newClaudeTUIOutputFilter(prompt, baselineAssistant) } return passthroughOutputFilter{} } @@ -50,10 +50,11 @@ func isClaudeTerminalProfile(target string, profile config.CLIProfileConf) bool } type claudeTUIOutputFilter struct { - raw strings.Builder - prompt string - emitted string - seenAssistant bool + raw strings.Builder + prompt string + baselineAssistant string + emitted string + seenAssistant bool } type claudeMessageSnapshot struct { @@ -61,8 +62,12 @@ type claudeMessageSnapshot struct { closed bool } -func newClaudeTUIOutputFilter(prompt string) *claudeTUIOutputFilter { - return &claudeTUIOutputFilter{prompt: strings.TrimSpace(prompt)} +func newClaudeTUIOutputFilter(prompt string, baselineAssistant ...string) *claudeTUIOutputFilter { + filter := &claudeTUIOutputFilter{prompt: strings.TrimSpace(prompt)} + if len(baselineAssistant) > 0 { + filter.baselineAssistant = strings.TrimSpace(baselineAssistant[0]) + } + return filter } func (f *claudeTUIOutputFilter) Filter(chunk string) string { @@ -93,6 +98,13 @@ func (f *claudeTUIOutputFilter) CompletionMessage() string { func (f *claudeTUIOutputFilter) currentMessage() claudeMessageSnapshot { screen := status.RenderVisibleScreen(f.raw.String(), terminalRows, terminalCols) clean := cleanClaudeTerminalOutput(f.raw.String()) + if promptMessage, promptClosed, promptSeen := latestClaudeAssistantMessageAfterPromptFromCleanOutput(clean, f.prompt); promptSeen { + if promptMessage == "" || isClaudePromptEchoMessage(promptMessage, f.prompt) { + return claudeMessageSnapshot{} + } + f.seenAssistant = true + return claudeMessageSnapshot{text: promptMessage, closed: promptClosed} + } message, closed := latestClaudeAssistantMessage(screen) cleanMessage, cleanClosed := latestClaudeAssistantMessageFromCleanOutput(clean) if cleanMessage != "" { @@ -100,6 +112,9 @@ func (f *claudeTUIOutputFilter) currentMessage() claudeMessageSnapshot { cleanMessage = "" } } + if f.emitted == "" && f.baselineAssistant != "" && cleanMessage == f.baselineAssistant { + cleanMessage = "" + } if cleanMessage != "" { f.seenAssistant = true if cleanMessage == message { @@ -112,6 +127,10 @@ func (f *claudeTUIOutputFilter) currentMessage() claudeMessageSnapshot { message = "" closed = false } + if f.emitted == "" && f.baselineAssistant != "" && message == f.baselineAssistant { + message = "" + closed = false + } if message != "" { f.seenAssistant = true } @@ -220,7 +239,56 @@ func latestClaudeAssistantMessageFromCleanOutput(text string) (string, bool) { if start < 0 { return "", false } + return claudeAssistantMessageFromCleanLines(lines, start) +} +func latestClaudeAssistantMessageAfterPromptFromCleanOutput(text, prompt string) (string, bool, bool) { + normalizedPrompt := normalizeClaudePromptEchoText(prompt) + if normalizedPrompt == "" { + return "", false, false + } + + lines := strings.SplitAfter(text, "\n") + var assistantIndexes []int + var promptIndexes []int + for i, raw := range lines { + line := strings.TrimSpace(strings.TrimRight(raw, "\r\n")) + if _, ok := claudeAssistantLine(line); ok { + assistantIndexes = append(assistantIndexes, i) + continue + } + if isClaudePromptEchoLine(line, normalizedPrompt) { + promptIndexes = append(promptIndexes, i) + } + } + + start := -1 + for _, assistantIndex := range assistantIndexes { + for _, promptIndex := range promptIndexes { + if promptIndex < assistantIndex { + start = assistantIndex + break + } + } + } + if start >= 0 { + message, closed := claudeAssistantMessageFromCleanLines(lines, start) + return message, closed, true + } + + lastAssistantIndex := -1 + if len(assistantIndexes) > 0 { + lastAssistantIndex = assistantIndexes[len(assistantIndexes)-1] + } + for _, promptIndex := range promptIndexes { + if promptIndex > lastAssistantIndex { + return "", false, true + } + } + return "", false, false +} + +func claudeAssistantMessageFromCleanLines(lines []string, start int) (string, bool) { out := make([]string, 0, len(lines)-start) closed := false if text, ok := claudeAssistantLine(strings.TrimRight(lines[start], "\r\n")); ok { @@ -248,6 +316,22 @@ func latestClaudeAssistantMessageFromCleanOutput(text string) (string, bool) { return strings.Join(trimTrailingEmptyLines(out), "\n"), closed } +func isClaudePromptEchoLine(line, normalizedPrompt string) bool { + line = strings.TrimSpace(line) + if line == "" || isClaudeTUIChromeLine(line) { + return false + } + normalizedLine := normalizeClaudePromptEchoText(line) + minLen := 6 + if len(normalizedPrompt) < minLen { + minLen = len(normalizedPrompt) + } + if len(normalizedLine) < minLen { + return false + } + return strings.Contains(normalizedPrompt, normalizedLine) +} + func appendClaudeMessageLine(out *[]string, line string) { line = strings.TrimSpace(line) if line == "" || isClaudeTUIBoundaryLine(line) { diff --git a/apps/node/internal/adapters/cli/persistent_output_filter_test.go b/apps/node/internal/adapters/cli/persistent_output_filter_test.go index 30596e8..101fecc 100644 --- a/apps/node/internal/adapters/cli/persistent_output_filter_test.go +++ b/apps/node/internal/adapters/cli/persistent_output_filter_test.go @@ -36,6 +36,33 @@ func TestClaudeTUIOutputFilterSuppressesRepaintedMessage(t *testing.T) { } } +func TestClaudeTUIOutputFilterSuppressesPreviousAssistantWhilePromptEchoes(t *testing.T) { + filter := newClaudeTUIOutputFilter("second prompt", "OLD_REPLY") + + for _, chunk := range []string{ + "\u25cf OLD_REPLY\r\n\u276f second", + " prompt\r\n", + } { + if got := filter.Filter(chunk); got != "" { + t.Fatalf("Filter(%q) = %q, want previous reply suppressed", chunk, got) + } + } + + got := filter.Filter("\u25cf NEW_REPLY\r\n\u276f ") + if got != "NEW_REPLY" { + t.Fatalf("Filter() = %q, want new assistant reply", got) + } +} + +func TestClaudeTUIOutputFilterAllowsSameTextAfterCurrentPrompt(t *testing.T) { + filter := newClaudeTUIOutputFilter("repeat prompt", "SAME_REPLY") + + got := filter.Filter("\u25cf SAME_REPLY\r\n\u276f repeat prompt\r\n\u25cf SAME_REPLY\r\n\u276f ") + if got != "SAME_REPLY" { + t.Fatalf("Filter() = %q, want repeated reply after current prompt", got) + } +} + func TestClaudeTUIOutputFilterUsesLatestAssistantMessage(t *testing.T) { filter := newClaudeTUIOutputFilter("") @@ -48,7 +75,7 @@ func TestClaudeTUIOutputFilterUsesLatestAssistantMessage(t *testing.T) { } func TestPassthroughOutputFilterKeepsNonClaudeTerminalOutput(t *testing.T) { - filter := newPersistentOutputFilter("raw-terminal", testProfile("sh", true), "") + filter := newPersistentOutputFilter("raw-terminal", testProfile("sh", true), "", "") got := filter.Filter("\x1b[31mreply:hello\x1b[0m")