diff --git a/.gitignore b/.gitignore index 0b6e74c5..de1e92ee 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,11 @@ coverage.out # Claude Code local settings (personal permissions, not committed) .claude/settings.local.json + +# Sensitive files +.env +.env.* +*.pem +*.key +credentials.* +secrets.* diff --git a/docs/plans/420-watcher-pane-rendering.md b/docs/plans/420-watcher-pane-rendering.md new file mode 100644 index 00000000..11776e41 --- /dev/null +++ b/docs/plans/420-watcher-pane-rendering.md @@ -0,0 +1,98 @@ +# 420 — Watcher Pane Rendering Fixes + +## Problem + +Three rendering defects in the watcher/approvals panes: + +1. **F1 — Approvals render below help bar**: expanded approvals appear + below the bottom chrome, unwrapped, and never show `ask.Body`. Users + can't review what they're approving. +2. **F2 — Markers repeated on every line**: `prefixLines` prepends the + šŸ“”/šŸ¤– marker to every line of a `---` block. Multi-line verdicts and + streaming agent output get cluttered with duplicate emojis. +3. **F3 — Verdict markdown rendered raw**: watcher pane shows raw + markdown syntax (`**bold**`, `` `code` ``) instead of rendered text. + +## Approach + +### F1 — Approvals in watcher pane slot + +Expanded approvals render into the watcher pane slot (views.go ~121), +temporarily replacing the watcher viewport. `RenderExpanded` shows +wrapped titles, action+target lines using snapshotted +`ask.IncidentID`/`IncidentTitle` (never live `m.selectedIncident`), +and full `ask.Body`. Watcher expanded state is saved on open and +restored on close via `watcherWasExpanded`. + +### F2 — One marker per block + +Introduced `prefixMessage(marker, text)` which prepends the marker once +at the start of the block, replacing `prefixLines` at all call sites. +Both watcher and agent paths fixed identically. + +### F3 — Glamour markdown rendering + +Added `renderWatcherMarkdown` method that runs content through glamour +with the model's `GlamourStyle`. ASCII-mode guard falls back to plain +lipgloss wrapping in deterministic test environments. `stripControl` +security sanitisation is preserved at the buffer-append boundary. + +## Key decisions + +- **Security**: approval rendering uses snapshotted incident data from + `Ask` struct, never reads live `m.selectedIncident` +- **ASCII guard**: `renderWatcherMarkdown` checks + `lipgloss.ColorProfile() == termenv.Ascii` to prevent ANSI + contamination in golden snapshot tests +- **No `prefixLines` removal**: kept as utility (still tested); all + production call sites use `prefixMessage` + +## Deliverables + +| ID | Summary | Files | Commit | +|----|---------|-------|--------| +| T1 | Failing tests for F1, F2, F3 | `watcher_rendering_test.go` | `88c8dc8` | +| F2 | One marker per `---` block | `watcher.go`, `tui.go`, `claude.go`, `claude_test.go`, `view_render_test.go`, `watcher_integration_test.go` | `05f5259` | +| F3 | Glamour markdown rendering | `watcher.go` | `85b7d67` | +| F1 | Approvals in watcher pane slot | `views.go`, `approvals.go`, `model.go`, `msgHandlers.go` | `02aa812` | +| G | Golden snapshots + ASCII guard + lint fix | `golden_test.go`, `watcher.go`, `watcher_rendering_test.go`, `approvals.go`, `model.go`, `testdata/*.golden` | `c4caa08` | + +## Test traceability + +| Test | File:Line | Covers | +|------|-----------|--------| +| `TestPrefixMessage_OneMarkerPerBlock` (6 subtests) | `watcher_rendering_test.go:16` | F2 | +| `TestWatcherBuffer_OneMarkerPerBlock_Integration` | `watcher_rendering_test.go:77` | F2 | +| `TestWatcherBuffer_StreamingSetLast_OneMarker` | `watcher_rendering_test.go:98` | F2 | +| `TestView_ApprovalsExpandedRendersInWatcherSlot` | `watcher_rendering_test.go:117` | F1 | +| `TestView_ApprovalsExpandedWrapsLongTitles` | `watcher_rendering_test.go:144` | F1 | +| `TestView_ApprovalsCollapsedShowsBadge` | `watcher_rendering_test.go:169` | F1 | +| `TestView_ApprovalsRestoredWatcherStateOnClose` | `watcher_rendering_test.go:182` | F1 | +| `TestUpdateWatcherViewport_RendersMarkdown` | `watcher_rendering_test.go:200` | F3 | +| `TestRenderApprovalsExpanded_ShowsBody` | `watcher_rendering_test.go:220` | F1 | +| `TestRenderApprovalsExpanded_ActionTargetLine` | `watcher_rendering_test.go:238` | F1 | +| `TestGolden_WatcherOneMarkerAgent` | `golden_test.go:193` | F2 golden | +| `TestGolden_WatcherOneMarkerWatcher` | `golden_test.go:203` | F2/F3 golden | +| `TestGolden_ApprovalsExpanded` | `golden_test.go:213` | F1 golden | + +## Revert-check evidence + +**F2** — reverted `prefixMessage` to delegate to `prefixLines`: 6 test +failures (marker count 3 instead of 1). Restored; all pass. + +**F3** — reverted `renderWatcherMarkdown` to plain lipgloss wrapping: +build failure from unused glamour/termenv imports (function body removed). +Restored; all pass. + +**F1** — reverted `views.go` approvals branch: 3 assertion failures +(header, title, body missing from view output). Restored; all pass. + +## CI results + +- `gofmt -s -l`: clean +- `go vet ./...`: clean +- `golangci-lint run`: 0 issues +- `go test ./pkg/tui/... -count=1`: PASS (47.8s) +- `go test -race ./pkg/tui/... -count=1`: PASS (96.0s) +- `cmd` package: pre-existing env-specific failure (`TestConfigureLogging_SetsLogWriter` + — `/var/log/srepd.log` read-only) unrelated to this PR diff --git a/pkg/ai/http_safety_test.go b/pkg/ai/http_safety_test.go new file mode 100644 index 00000000..051d17a3 --- /dev/null +++ b/pkg/ai/http_safety_test.go @@ -0,0 +1,66 @@ +package ai + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewOpenAICompatProvider_RejectsHTTPWithAPIKey(t *testing.T) { + _, err := newOpenAICompatProvider(Config{ + Endpoint: "http://remote-server.example.com:8080", + Model: "gpt-4", + }, "sk-secret-key") + + require.Error(t, err, + "must reject non-localhost http:// endpoint when API key is set") + assert.Contains(t, err.Error(), "HTTPS", + "error message should mention HTTPS requirement") +} + +func TestNewOpenAICompatProvider_AllowsHTTPLocalhost(t *testing.T) { + tests := []struct { + name string + endpoint string + }{ + {"localhost", "http://localhost:8080"}, + {"127.0.0.1", "http://127.0.0.1:11434"}, + {"[::1]", "http://[::1]:8080"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider, err := newOpenAICompatProvider(Config{ + Endpoint: tt.endpoint, + Model: "m", + }, "sk-secret-key") + + assert.NoError(t, err, + "localhost http:// with API key should be allowed") + assert.NotNil(t, provider) + }) + } +} + +func TestNewOpenAICompatProvider_AllowsHTTPSWithAPIKey(t *testing.T) { + provider, err := newOpenAICompatProvider(Config{ + Endpoint: "https://api.openai.com", + Model: "gpt-4", + }, "sk-secret-key") + + assert.NoError(t, err, + "https:// endpoint with API key should be allowed") + assert.NotNil(t, provider) +} + +func TestNewOpenAICompatProvider_AllowsHTTPWithoutAPIKey(t *testing.T) { + provider, err := newOpenAICompatProvider(Config{ + Endpoint: "http://remote-server.example.com:8080", + Model: "llama", + }, "") + + assert.NoError(t, err, + "http:// without API key should be allowed (e.g., ollama)") + assert.NotNil(t, provider) +} diff --git a/pkg/ai/openai_compat.go b/pkg/ai/openai_compat.go index a9edfa85..1cbe184e 100644 --- a/pkg/ai/openai_compat.go +++ b/pkg/ai/openai_compat.go @@ -7,12 +7,27 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "strings" "time" "github.com/charmbracelet/log" ) +func validateEndpointSecurity(endpoint string) error { + parsed, err := url.Parse(endpoint) + if err != nil { + return fmt.Errorf("openai: invalid endpoint URL: %w", err) + } + if parsed.Scheme == "http" { + host := parsed.Hostname() + if host != "localhost" && host != "127.0.0.1" && host != "::1" { + return fmt.Errorf("openai: refusing to send API key over HTTP to non-localhost host %q; use HTTPS for remote endpoints", host) + } + } + return nil +} + type openaiCompatProvider struct { endpoint string model string @@ -26,6 +41,12 @@ func newOpenAICompatProvider(cfg Config, apiKey string) (*openaiCompatProvider, return nil, fmt.Errorf("openai: endpoint is required") } + if apiKey != "" { + if err := validateEndpointSecurity(cfg.Endpoint); err != nil { + return nil, err + } + } + return &openaiCompatProvider{ endpoint: strings.TrimRight(cfg.Endpoint, "/"), model: cfg.Model, diff --git a/pkg/backplane/client.go b/pkg/backplane/client.go index ae79a6a1..2f92b63d 100644 --- a/pkg/backplane/client.go +++ b/pkg/backplane/client.go @@ -60,7 +60,7 @@ func NewClient(cfg *Config, tokenFunc func() (string, error)) BackplaneClient { } func (c *Client) ListReports(ctx context.Context, clusterID string) ([]ReportSummary, error) { - endpoint := fmt.Sprintf("%s/backplane/cluster/%s/reports?last=10", c.config.URL, clusterID) + endpoint := fmt.Sprintf("%s/backplane/cluster/%s/reports?last=10", c.config.URL, url.PathEscape(clusterID)) log.Debug("backplane.ListReports", "cluster_id", clusterID) body, err := c.doRequest(ctx, endpoint) @@ -80,7 +80,7 @@ func (c *Client) ListReports(ctx context.Context, clusterID string) ([]ReportSum } func (c *Client) GetReport(ctx context.Context, clusterID, reportID string) (*Report, error) { - endpoint := fmt.Sprintf("%s/backplane/cluster/%s/reports/%s", c.config.URL, clusterID, reportID) + endpoint := fmt.Sprintf("%s/backplane/cluster/%s/reports/%s", c.config.URL, url.PathEscape(clusterID), url.PathEscape(reportID)) log.Debug("backplane.GetReport", "cluster_id", clusterID, "report_id", reportID) body, err := c.doRequest(ctx, endpoint) diff --git a/pkg/backplane/path_escape_test.go b/pkg/backplane/path_escape_test.go new file mode 100644 index 00000000..dcb6b648 --- /dev/null +++ b/pkg/backplane/path_escape_test.go @@ -0,0 +1,69 @@ +package backplane + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_ListReports_PathEscapesClusterID(t *testing.T) { + var receivedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.EscapedPath() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(listReportsResponse{Reports: []ReportSummary{}}) + })) + defer server.Close() + + cfg := &Config{URL: server.URL} + client := NewClient(cfg, func() (string, error) { return "test-token", nil }) + + _, err := client.ListReports(context.Background(), "../../admin/endpoint") + require.NoError(t, err) + + assert.Equal(t, "/backplane/cluster/..%2F..%2Fadmin%2Fendpoint/reports", receivedPath, + "clusterID with path traversal must be URL-escaped in the request path") +} + +func TestClient_GetReport_PathEscapesClusterIDAndReportID(t *testing.T) { + var receivedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.EscapedPath() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(Report{ReportID: "rpt-1"}) + })) + defer server.Close() + + cfg := &Config{URL: server.URL} + client := NewClient(cfg, func() (string, error) { return "test-token", nil }) + + _, err := client.GetReport(context.Background(), "../admin", "../../secret") + require.NoError(t, err) + + assert.Equal(t, "/backplane/cluster/..%2Fadmin/reports/..%2F..%2Fsecret", receivedPath, + "both clusterID and reportID with path traversal must be URL-escaped") +} + +func TestClient_ListReports_NormalClusterIDUnchanged(t *testing.T) { + var receivedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(listReportsResponse{Reports: []ReportSummary{}}) + })) + defer server.Close() + + cfg := &Config{URL: server.URL} + client := NewClient(cfg, func() (string, error) { return "test-token", nil }) + + _, err := client.ListReports(context.Background(), "abc-123-def") + require.NoError(t, err) + + assert.Equal(t, "/backplane/cluster/abc-123-def/reports", receivedPath, + "normal clusterID should pass through unchanged") +} diff --git a/pkg/tui/approvals.go b/pkg/tui/approvals.go index 078749e8..9d9b6395 100644 --- a/pkg/tui/approvals.go +++ b/pkg/tui/approvals.go @@ -118,6 +118,8 @@ func (a *approvalsStrip) RenderExpanded(width int) string { return "" } + wrapStyle := lipgloss.NewStyle().Width(width) + var lines []string header := lipgloss.NewStyle(). Bold(true). @@ -125,6 +127,7 @@ func (a *approvalsStrip) RenderExpanded(width int) string { Width(width). Render(" Pending Approvals ") lines = append(lines, header) + lines = append(lines, "") for i, ask := range a.asks { prefix := " " @@ -132,18 +135,51 @@ func (a *approvalsStrip) RenderExpanded(width int) string { prefix = "> " } kindLabel := askKindLabel(ask.Kind) - line := fmt.Sprintf("%s[%s] %s", prefix, kindLabel, ask.Title) - lines = append(lines, line) + + titleLine := fmt.Sprintf("%s[%s] %s", prefix, kindLabel, ask.Title) + lines = append(lines, wrapStyle.Render(titleLine)) + + // Action target line: what this approval will do and to which incident + var target string + switch ask.Kind { + case AskDraftNote: + target = fmt.Sprintf(" Post note to incident %s", ask.IncidentID) + case AskSuggestedCommand: + target = " Copy command to clipboard" + case AskEscalationSuggestion: + target = fmt.Sprintf(" Re-escalate incident %s", ask.IncidentID) + default: + target = fmt.Sprintf(" Action on incident %s", ask.IncidentID) + } + if ask.IncidentTitle != "" { + target += fmt.Sprintf(" (%s)", ask.IncidentTitle) + } + lines = append(lines, wrapStyle.Render(target)) + + // Body: the content the user is approving + if ask.Body != "" { + lines = append(lines, "") + bodyLines := strings.Split(ask.Body, "\n") + for _, bl := range bodyLines { + lines = append(lines, wrapStyle.Render(" "+bl)) + } + } + + if i < len(a.asks)-1 { + lines = append(lines, "") + } } + lines = append(lines, "") footer := " [Enter] Accept [d] Dismiss [Esc] Close " lines = append(lines, footer) - result := "" + var result strings.Builder for _, l := range lines { - result += l + "\n" + result.WriteString(l) + result.WriteString("\n") } - return result + return result.String() } // inferAskKind determines the AskKind from a verdict's action text. diff --git a/pkg/tui/claude.go b/pkg/tui/claude.go index 3e19dae3..56ea363b 100644 --- a/pkg/tui/claude.go +++ b/pkg/tui/claude.go @@ -252,19 +252,19 @@ func (m model) handleAgentSessionEvent(msg agentSessionEventMsg) (tea.Model, tea } m.agentSessionInitSeen = true m.agentStreamPartial = "" - m.watcherBuffer.Append(prefixLines(m.agentMarker, "")) + m.watcherBuffer.Append(prefixMessage(m.agentMarker, "")) m.updateWatcherViewport() return m, readAgentSessionCmd(msg.session) case agent.TextDelta: m.agentStreamPartial += ev.Text - m.watcherBuffer.SetLast(prefixLines(m.agentMarker, m.agentStreamPartial)) + m.watcherBuffer.SetLast(prefixMessage(m.agentMarker, m.agentStreamPartial)) m.updateWatcherViewport() return m, readAgentSessionCmd(msg.session) case agent.ToolUse: toolLine := fmt.Sprintf("āš™ %s %s", ev.Tool, ev.ToolInput) - m.watcherBuffer.Append(prefixLines(m.agentMarker, toolLine)) + m.watcherBuffer.Append(prefixMessage(m.agentMarker, toolLine)) m.updateWatcherViewport() return m, readAgentSessionCmd(msg.session) @@ -279,16 +279,8 @@ func (m model) handleAgentSessionEvent(msg agentSessionEventMsg) (tea.Model, tea return errMsg{fmt.Errorf("agent error: %s", ev.Text)} } } - // Render final result through glamour if available - if ev.Text != "" && m.markdownRenderer != nil { - rendered, err := m.markdownRenderer.Render(ev.Text) - if err == nil { - m.watcherBuffer.SetLast(prefixLines(m.agentMarker, strings.TrimSpace(rendered))) - } else { - m.watcherBuffer.SetLast(prefixLines(m.agentMarker, ev.Text)) - } - } else if ev.Text != "" { - m.watcherBuffer.SetLast(prefixLines(m.agentMarker, ev.Text)) + if ev.Text != "" { + m.watcherBuffer.SetLast(prefixMessage(m.agentMarker, ev.Text)) } m.updateWatcherViewport() m.setStatus("agent response received") diff --git a/pkg/tui/claude_test.go b/pkg/tui/claude_test.go index 7d972946..be26f4ec 100644 --- a/pkg/tui/claude_test.go +++ b/pkg/tui/claude_test.go @@ -864,7 +864,7 @@ func TestAgentStreamChunkMsg_AppendsText(t *testing.T) { m.claudeQuerying = true m.agentStreamPartial = "hello " m.watcherExpanded = true - m.watcherBuffer.Append(prefixLines(m.agentMarker, "hello ")) + m.watcherBuffer.Append(prefixMessage(m.agentMarker, "hello ")) ch := make(chan streamEvent, 1) ch <- streamEvent{text: "more"} diff --git a/pkg/tui/fix_review_test.go b/pkg/tui/fix_review_test.go new file mode 100644 index 00000000..fa42dd7e --- /dev/null +++ b/pkg/tui/fix_review_test.go @@ -0,0 +1,458 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + + "github.com/PagerDuty/go-pagerduty" + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/clcollins/srepd/pkg/agent" + "github.com/clcollins/srepd/pkg/ai/tools" + "github.com/clcollins/srepd/pkg/pd" + "github.com/muesli/termenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- F1: Approvals key must only open from table view --- + +func TestApprovalsKey_BlockedInIncidentView(t *testing.T) { + m := sizedTestModel(t) + m.viewingIncident = true + m.approvals = newApprovalsStrip() + m.approvals.Add(Ask{Kind: AskDraftNote, Title: "test", IncidentID: "P1234567"}) + + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'A'}}) + updated := result.(model) + + assert.False(t, updated.approvalsExpanded, + "A key must not open approvals overlay while viewing an incident") +} + +func TestApprovalsKey_BlockedInLogView(t *testing.T) { + m := sizedTestModel(t) + m.viewingLog = true + m.approvals = newApprovalsStrip() + m.approvals.Add(Ask{Kind: AskDraftNote, Title: "test", IncidentID: "P1234567"}) + + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'A'}}) + updated := result.(model) + + assert.False(t, updated.approvalsExpanded, + "A key must not open approvals overlay while viewing logs") +} + +func TestApprovalsKey_BlockedInDocsView(t *testing.T) { + m := sizedTestModel(t) + m.viewingDocs = true + m.approvals = newApprovalsStrip() + m.approvals.Add(Ask{Kind: AskDraftNote, Title: "test", IncidentID: "P1234567"}) + + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'A'}}) + updated := result.(model) + + assert.False(t, updated.approvalsExpanded, + "A key must not open approvals overlay while viewing docs") +} + +func TestApprovalsKey_AllowedInTableView(t *testing.T) { + m := sizedTestModel(t) + m.approvals = newApprovalsStrip() + m.approvals.Add(Ask{Kind: AskDraftNote, Title: "test", IncidentID: "P1234567"}) + + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'A'}}) + updated := result.(model) + + assert.True(t, updated.approvalsExpanded, + "A key should open approvals overlay in table view") +} + +func TestApprovalsExpanded_RendersInAllViewModes(t *testing.T) { + m := sizedTestModel(t) + m.approvals = newApprovalsStrip() + m.approvals.Add(Ask{Kind: AskDraftNote, Title: "test ask", Body: "test body", IncidentID: "P1234567"}) + m.approvalsExpanded = true + m.watcherExpanded = true + m.recomputeLayout() + + view := m.View() + assert.Contains(t, view, "Pending Approvals", + "expanded approvals must be visible in table view") +} + +// --- F2: --- separator must not become setext heading --- + +func TestWatcherBuffer_MultiEntryNoSextextHeading(t *testing.T) { + m := sizedTestModel(t) + m.watcherExpanded = true + m.watcherBuffer = newWatcherBuffer(50) + m.watcherBuffer.Append(prefixMessage(m.watcherMarker, "First verdict about etcd")) + m.watcherBuffer.Append(prefixMessage(m.watcherMarker, "Second verdict about pods")) + m.recomputeLayout() + m.updateWatcherViewport() + + content := m.watcherViewport.View() + + assert.Contains(t, content, "First verdict", + "first entry must remain visible, not swallowed by heading") + assert.Contains(t, content, "Second verdict", + "second entry must remain visible") + assert.NotContains(t, content, "##", + "entries must not be rendered as markdown headings") +} + +func TestRenderWatcherMarkdown_IndividualEntries(t *testing.T) { + lipgloss.SetColorProfile(termenv.ANSI) + t.Cleanup(func() { lipgloss.SetColorProfile(termenv.Ascii) }) + + m := sizedTestModel(t) + m.watcherExpanded = true + m.watcherBuffer = newWatcherBuffer(50) + m.watcherBuffer.Append("**bold text** in entry one") + m.watcherBuffer.Append("entry two with `code`") + m.recomputeLayout() + m.updateWatcherViewport() + + content := m.watcherViewport.View() + + assert.NotContains(t, content, "**bold text**", + "raw markdown bold syntax should be rendered, not passed through") +} + +// --- F3: Agent results must not be double-rendered --- + +func TestAgentResult_NoDoubleRender(t *testing.T) { + lipgloss.SetColorProfile(termenv.ANSI) + t.Cleanup(func() { lipgloss.SetColorProfile(termenv.Ascii) }) + + m := sizedTestModel(t) + m.watcherExpanded = true + m.watcherBuffer = newWatcherBuffer(50) + m.claudeQuerying = true + m.recomputeLayout() + + // Simulate agent session Init + Result + result, _ := m.Update(agentSessionEventMsg{ + event: agent.Event{Kind: agent.Init}, + }) + m = result.(model) + + result, _ = m.Update(agentSessionEventMsg{ + event: agent.Event{Kind: agent.Result, Text: "**Important** finding with `code`"}, + }) + m = result.(model) + + content := m.watcherViewport.View() + + // Raw ev.Text should be stored and rendered once by updateWatcherViewport, + // not pre-rendered then re-rendered + assert.NotContains(t, content, "**Important**", + "raw bold markers must not survive — text should be glamour-rendered exactly once") + + // Double-rendering produces nested ANSI sequences where glamour + // re-processes its own output. Count NBSP occurrences: glamour uses + // exactly one pair around inline code spans. Double-rendering would + // produce extra pairs. + nbspCount := strings.Count(content, "Ā ") + assert.LessOrEqual(t, nbspCount, 2, + "at most one NBSP pair (around `code`); double-rendering would produce more") +} + +// --- F4: IncidentTitle must be sanitized --- + +func TestBuildAskFromVerdict_SanitizesIncidentTitle(t *testing.T) { + m := createTestModel() + m.config = &pd.Config{Client: &pd.MockPagerDutyClient{}} + + incident := pagerduty.Incident{ + APIObject: pagerduty.APIObject{ID: "INC-1"}, + Title: "Alert \x1b[31mred\x1b[0m injection \x1b]0;pwned\x07", + } + m.incidentList = []pagerduty.Incident{incident} + + verdict := tools.Verdict{ + Tier: tools.TierActionable, + Summary: "Post note", + Action: "Note content", + } + + ask := m.buildAskFromVerdict(verdict, []string{"INC-1"}) + + assert.NotContains(t, ask.IncidentTitle, "\x1b", + "ANSI escape sequences must be stripped from IncidentTitle") + assert.NotContains(t, ask.IncidentTitle, "\x07", + "BEL character must be stripped from IncidentTitle") + assert.Contains(t, ask.IncidentTitle, "Alert", + "visible text must be preserved") + assert.Contains(t, ask.IncidentTitle, "injection", + "visible text must be preserved") +} + +func TestBuildAskFromVerdict_SanitizesSelectedIncidentTitle(t *testing.T) { + m := createTestModel() + m.config = &pd.Config{Client: &pd.MockPagerDutyClient{}} + m.selectedIncident = &pagerduty.Incident{ + APIObject: pagerduty.APIObject{ID: "INC-2"}, + Title: "Fallback \x1b[31mred\x1b[0m title", + } + + verdict := tools.Verdict{ + Tier: tools.TierActionable, + Summary: "Post note", + Action: "Note content", + } + + // No originating incidents — falls back to selectedIncident + ask := m.buildAskFromVerdict(verdict, nil) + + assert.NotContains(t, ask.IncidentTitle, "\x1b", + "ANSI escapes must be stripped from fallback IncidentTitle") + assert.Contains(t, ask.IncidentTitle, "Fallback", + "visible text must be preserved") +} + +// --- F5: Approvals pane must not exceed WatcherHeight --- + +func TestApprovalsPane_HeightClamped(t *testing.T) { + m := sizedTestModel(t) + m.approvals = newApprovalsStrip() + + // Add a long-body ask that would exceed WatcherHeight + longBody := strings.Repeat("This is a very long line of approval body text.\n", 40) + m.approvals.Add(Ask{ + Kind: AskDraftNote, + Title: "Long approval", + Body: longBody, + IncidentID: "P1234567", + }) + m.approvalsExpanded = true + m.watcherExpanded = true + m.recomputeLayout() + + view := m.View() + lines := strings.Split(view, "\n") + lineCount := len(lines) + if lineCount > 0 && lines[lineCount-1] == "" { + lineCount-- + } + + assert.LessOrEqual(t, lineCount, 40, + "view with long approvals body must not exceed terminal height (%d lines)", lineCount) +} + +// --- F6: Renderer caching --- + +func TestRenderWatcherMarkdown_CachesRenderer(t *testing.T) { + m := sizedTestModel(t) + m.watcherExpanded = true + m.recomputeLayout() + + // Call twice with same width — should reuse cached renderer + result1 := m.renderWatcherMarkdown("**bold**", 80) + result2 := m.renderWatcherMarkdown("**bold**", 80) + + assert.Equal(t, result1, result2, + "same width should produce identical output (cached renderer)") + + // Different width should still work + result3 := m.renderWatcherMarkdown("**bold**", 60) + assert.NotEmpty(t, result3, "different width should still render") +} + +// --- Minor: Watcher/Input keys must not fire under approvals overlay --- + +func TestWatcherKey_BlockedUnderApprovalsOverlay(t *testing.T) { + m := sizedTestModel(t) + m.approvals = newApprovalsStrip() + m.approvals.Add(Ask{Kind: AskDraftNote, Title: "test", IncidentID: "P1234567"}) + m.approvalsExpanded = true + m.watcherExpanded = true + m.recomputeLayout() + + wasExpanded := m.watcherExpanded + + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'w'}}) + updated := result.(model) + + assert.Equal(t, wasExpanded, updated.watcherExpanded, + "w key must not toggle watcher while approvals overlay is open") +} + +func TestInputKey_BlockedUnderApprovalsOverlay(t *testing.T) { + m := sizedTestModel(t) + m.config = &pd.Config{Client: &pd.MockPagerDutyClient{}} + m.approvals = newApprovalsStrip() + m.approvals.Add(Ask{Kind: AskDraftNote, Title: "test", IncidentID: "P1234567"}) + m.approvalsExpanded = true + m.watcherExpanded = true + m.recomputeLayout() + + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{':'}}) + updated := result.(model) + + assert.False(t, updated.input.Focused(), + ": key must not open input while approvals overlay is open") +} + +// --- Helper to check no line exceeds width with approvals --- + +func TestView_ApprovalsExpandedNoLineExceedsWidth(t *testing.T) { + for _, width := range []int{60, 80, 120} { + t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) { + m := createTestModelWithSelectedIncident() + m.config.Client = &pd.MockPagerDutyClient{} + size := tea.WindowSizeMsg{Width: width, Height: 40} + windowSize = size + result, _ := m.Update(size) + m = result.(model) + m.table.SetRows([]table.Row{ + {dot, "P1234567", "Test Alert Firing", "test-service"}, + }) + + m.approvals = newApprovalsStrip() + m.approvals.Add(Ask{ + Kind: AskDraftNote, + Title: "A very long approval title that should be wrapped properly within bounds", + Body: "Body text that is also quite long and should be wrapped within the pane width", + IncidentID: "P1234567", + IncidentTitle: "Test Alert Firing", + }) + m.approvalsExpanded = true + m.watcherExpanded = true + m.recomputeLayout() + + view := m.View() + for i, line := range strings.Split(view, "\n") { + w := lipgloss.Width(line) + assert.LessOrEqual(t, w, width, + "approvals line %d is %d cols wide (limit %d): %q", + i, w, width, line) + } + }) + } +} + +// --- Golden tests for new rendering --- + +func TestGolden_WatcherMultiEntry(t *testing.T) { + m := goldenTestModel(t) + m.watcherExpanded = true + m.watcherBuffer = newWatcherBuffer(50) + m.watcherBuffer.Append(prefixMessage(m.watcherMarker, "**Key Findings:**\n- etcd member down")) + m.watcherBuffer.Append(prefixMessage(m.watcherMarker, "**Follow-up:**\n- Cluster recovered")) + m.recomputeLayout() + m.updateWatcherViewport() + + view := m.View() + + // In ASCII mode (golden tests), glamour is bypassed, but entries must still + // be separate and not corrupted by setext heading interpretation + assert.Contains(t, view, "Key Findings", + "first entry should be visible") + assert.Contains(t, view, "Follow-up", + "second entry should be visible") +} + +// Verify golden snapshot for multi-entry watcher +func TestGolden_WatcherMultiEntrySnapshot(t *testing.T) { + m := goldenTestModel(t) + m.watcherExpanded = true + m.watcherBuffer = newWatcherBuffer(50) + m.watcherBuffer.Append(prefixMessage(m.watcherMarker, "First observation about cluster storm")) + m.watcherBuffer.Append(prefixMessage(m.agentMarker, "Analysis complete: etcd quorum is safe")) + m.recomputeLayout() + m.updateWatcherViewport() + + require.NotEmpty(t, m.View(), "multi-entry watcher view must not be empty") +} + +// --- SEC-004b: PagerDuty data must be sanitized before rendering --- + +func TestSummarizeIncident_SanitizesFields(t *testing.T) { + inc := &pagerduty.Incident{ + APIObject: pagerduty.APIObject{ID: "P123"}, + Title: "Alert \x1b[31mred\x1b[0m injection", + Service: pagerduty.APIObject{ + Summary: "svc \x1b]0;pwned\x07 name", + }, + EscalationPolicy: pagerduty.APIObject{ + Summary: "policy \x1b[2Jclear", + }, + } + + s := summarizeIncident(inc) + + assert.NotContains(t, s.Title, "\x1b", + "incident title must be stripped of ANSI escapes") + assert.Contains(t, s.Title, "Alert", + "visible text must be preserved in title") + assert.NotContains(t, s.Service, "\x1b", + "service name must be stripped of ANSI escapes") + assert.NotContains(t, s.Service, "\x07", + "BEL must be stripped from service name") + assert.NotContains(t, s.EscalationPolicy, "\x1b", + "escalation policy must be stripped of ANSI escapes") +} + +func TestSummarizeNotes_SanitizesContent(t *testing.T) { + notes := []pagerduty.IncidentNote{ + { + ID: "N1", + Content: "Note with \x1b[31mANSI\x1b[0m and \x1b]0;title\x07 injection", + User: pagerduty.APIObject{ + Summary: "user \x1b[2J clear-screen", + }, + }, + } + + summaries := summarizeNotes(notes) + require.Len(t, summaries, 1) + + assert.NotContains(t, summaries[0].Content, "\x1b", + "note content must be stripped of ANSI escapes") + assert.NotContains(t, summaries[0].Content, "\x07", + "BEL must be stripped from note content") + assert.NotContains(t, summaries[0].User, "\x1b", + "note user must be stripped of ANSI escapes") + assert.Contains(t, summaries[0].Content, "Note with", + "visible text must be preserved") +} + +func TestSummarizeAlerts_SanitizesFields(t *testing.T) { + alerts := []pagerduty.IncidentAlert{ + { + APIObject: pagerduty.APIObject{ID: "A1"}, + Service: pagerduty.APIObject{ + Summary: "evil-svc \x1b[31mred\x1b[0m", + }, + Body: map[string]interface{}{ + "details": map[string]interface{}{ + "alert_name": "alert \x1b[2Jclear", + }, + }, + }, + } + + summaries := summarizeAlerts(alerts, nil) + require.Len(t, summaries, 1) + + assert.NotContains(t, summaries[0].Service, "\x1b", + "alert service must be stripped of ANSI escapes") + assert.NotContains(t, summaries[0].Name, "\x1b", + "alert name must be stripped of ANSI escapes") +} + +func TestTableRows_SanitizePDData(t *testing.T) { + title := "\x1b[31mEvil\x1b[0m Title" + sanitized := stripControl(title) + + assert.NotContains(t, sanitized, "\x1b", + "table row title must be stripped of ANSI escapes") + assert.Contains(t, sanitized, "Evil", + "visible text must be preserved") + assert.Contains(t, sanitized, "Title", + "visible text must be preserved") +} diff --git a/pkg/tui/golden_test.go b/pkg/tui/golden_test.go index 41ec0560..4d8704ff 100644 --- a/pkg/tui/golden_test.go +++ b/pkg/tui/golden_test.go @@ -183,9 +183,53 @@ func TestGolden_ChatMode(t *testing.T) { m.chatInput.Prompt = " > " m.chatInput.Focus() m.watcherBuffer = newWatcherBuffer(50) - m.watcherBuffer.Append(prefixLines(m.agentMarker, "Hello! How can I help?")) + m.watcherBuffer.Append(prefixMessage(m.agentMarker, "Hello! How can I help?")) m.recomputeLayout() m.updateChatViewport() m.chatViewportGotoBottom() golden.RequireEqual(t, m.View()) } + +func TestGolden_WatcherOneMarkerAgent(t *testing.T) { + m := goldenTestModel(t) + m.watcherExpanded = true + m.watcherBuffer = newWatcherBuffer(50) + m.watcherBuffer.Append(prefixMessage(m.agentMarker, "Hi! I'm here and watching the incident queue.\n\nStill the same situation on the cluster — waiting on AMS to fix\nthe ownership/role issue, then ocm-agent restart.")) + m.recomputeLayout() + m.updateWatcherViewport() + golden.RequireEqual(t, m.View()) +} + +func TestGolden_WatcherOneMarkerWatcher(t *testing.T) { + m := goldenTestModel(t) + m.watcherExpanded = true + m.watcherBuffer = newWatcherBuffer(50) + m.watcherBuffer.Append(prefixMessage(m.watcherMarker, "**Key Findings:**\n- `etcdMembersDownSRE` is a high-severity control plane alert\n- With ≤3 etcd members, losing even one member threatens quorum")) + m.recomputeLayout() + m.updateWatcherViewport() + golden.RequireEqual(t, m.View()) +} + +func TestGolden_ApprovalsExpanded(t *testing.T) { + m := goldenTestModel(t) + m.approvals = newApprovalsStrip() + m.approvals.Add(Ask{ + Kind: AskDraftNote, + Title: "OCM Agent notification failure on test-cluster.example.com", + Body: "OCM Agent has been unable to post ServiceLog notifications for ≄60 minutes.\nThis may indicate a permissions issue following cluster ownership transfer.", + IncidentID: "P1234567", + IncidentTitle: "Test Alert Firing", + }) + m.approvals.Add(Ask{ + Kind: AskSuggestedCommand, + Title: "Restart ocm-agent pod", + Body: "oc delete pod -n openshift-ocm-agent-operator -l app=ocm-agent", + IncidentID: "P1234567", + IncidentTitle: "Test Alert Firing", + }) + m.watcherWasExpanded = false + m.approvalsExpanded = true + m.watcherExpanded = true + m.recomputeLayout() + golden.RequireEqual(t, m.View()) +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 179a9c99..a13c0c7f 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -84,17 +84,19 @@ type model struct { input textinput.Model tagInputActive bool // This is a hack since viewport.Model doesn't have a Focused() method - viewingIncident bool - incidentViewer viewport.Model - viewingLog bool - logViewer viewport.Model - logFilePath string - logDestination string - startupTime time.Time - help help.Model - spinner spinner.Model - apiInProgress bool - markdownRenderer *glamour.TermRenderer + viewingIncident bool + incidentViewer viewport.Model + viewingLog bool + logViewer viewport.Model + logFilePath string + logDestination string + startupTime time.Time + help help.Model + spinner spinner.Model + apiInProgress bool + + markdownRenderer *glamour.TermRenderer + watcherRendererWidth int status string @@ -179,12 +181,13 @@ type model struct { typewriter *typewriterState // Tool investigation state (Phase 3 AI rearchitecture) - toolRegistry *tools.Registry - toolRunnerFactory ToolRunnerFactory - investigationCfg investigationConfig - approvals *approvalsStrip - approvalsExpanded bool - toolsLoggedOnce bool // whether non-Anthropic degradation was logged + toolRegistry *tools.Registry + toolRunnerFactory ToolRunnerFactory + investigationCfg investigationConfig + approvals *approvalsStrip + approvalsExpanded bool + watcherWasExpanded bool + toolsLoggedOnce bool // whether non-Anthropic degradation was logged // Live streaming state. When streamResponses is true and the provider supports // streaming, watcher responses are appended token-by-token as they arrive @@ -974,10 +977,10 @@ func (m *model) buildAskFromVerdict(verdict tools.Verdict, originatingIncidentID } if originInc != nil { ask.IncidentID = originInc.ID - ask.IncidentTitle = originInc.Title + ask.IncidentTitle = stripControl(originInc.Title) } else if m.selectedIncident != nil { ask.IncidentID = m.selectedIncident.ID - ask.IncidentTitle = m.selectedIncident.Title + ask.IncidentTitle = stripControl(m.selectedIncident.Title) } switch kind { diff --git a/pkg/tui/msgHandlers.go b/pkg/tui/msgHandlers.go index 6570d204..592fb931 100644 --- a/pkg/tui/msgHandlers.go +++ b/pkg/tui/msgHandlers.go @@ -178,12 +178,6 @@ func (m model) keyMsgHandler(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { return updatedIncidentListMsg{m.incidentList, nil} } } - if key.Matches(msg.(tea.KeyMsg), defaultKeyMap.Watcher) { - m.watcherExpanded = !m.watcherExpanded - m.recomputeLayout() - return m, nil - } - // Tag input: ctrl+t opens input with tag prompt if key.Matches(msg.(tea.KeyMsg), defaultKeyMap.Tag) { if m.table.SelectedRow() == nil { @@ -196,32 +190,7 @@ func (m model) keyMsgHandler(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Sequence(m.input.Focus()) } - // Commands for any focus mode - if key.Matches(msg.(tea.KeyMsg), defaultKeyMap.Input) { - keyStr := msg.(tea.KeyMsg).String() - if keyStr == ":" || keyStr == "/" { - m.input.SetValue(keyStr) - m.input.SetCursor(1) - } - return m, tea.Sequence( - m.input.Focus(), - ) - } - - // Approvals overlay: when expanded, route keys to the approvals handler - if m.approvalsExpanded { - return switchApprovalsFocusMode(m, msg) - } - - if key.Matches(msg.(tea.KeyMsg), defaultKeyMap.Approvals) { - if m.approvals != nil && m.approvals.Count() > 0 { - m.approvalsExpanded = true - return m, nil - } - return m, m.flashNotification("no pending approvals") - } - - // Default commands for the table view + // Per-mode dispatch for non-table views switch { case m.tourMode: return switchTourFocusMode(m, msg) @@ -252,11 +221,44 @@ func (m model) keyMsgHandler(msg tea.Msg) (tea.Model, tea.Cmd) { case m.viewingIncident: return switchIncidentFocusMode(m, msg) + } - case m.input.Focused(): - return switchInputFocusMode(m, msg) + // Below here: table view only (no modal view active) - case m.table.Focused(): + // Approvals overlay: when expanded, route all keys to the approvals handler + if m.approvalsExpanded { + return switchApprovalsFocusMode(m, msg) + } + + if key.Matches(msg.(tea.KeyMsg), defaultKeyMap.Watcher) { + m.watcherExpanded = !m.watcherExpanded + m.recomputeLayout() + return m, nil + } + + if key.Matches(msg.(tea.KeyMsg), defaultKeyMap.Input) { + keyStr := msg.(tea.KeyMsg).String() + if keyStr == ":" || keyStr == "/" { + m.input.SetValue(keyStr) + m.input.SetCursor(1) + } + return m, tea.Sequence( + m.input.Focus(), + ) + } + + if key.Matches(msg.(tea.KeyMsg), defaultKeyMap.Approvals) { + if m.approvals != nil && m.approvals.Count() > 0 { + m.watcherWasExpanded = m.watcherExpanded + m.approvalsExpanded = true + m.watcherExpanded = true + m.recomputeLayout() + return m, nil + } + return m, m.flashNotification("no pending approvals") + } + + if m.table.Focused() { return switchTableFocusMode(m, msg) } @@ -1107,6 +1109,12 @@ func (m *model) enterChatModeState() { m.chatViewportGotoBottom() } +func (m *model) closeApprovals() { + m.approvalsExpanded = false + m.watcherExpanded = m.watcherWasExpanded + m.recomputeLayout() +} + func switchApprovalsFocusMode(m model, msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: @@ -1115,7 +1123,7 @@ func switchApprovalsFocusMode(m model, msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case key.Matches(msg, defaultKeyMap.Back): - m.approvalsExpanded = false + m.closeApprovals() return m, nil case key.Matches(msg, defaultKeyMap.Up): @@ -1129,7 +1137,7 @@ func switchApprovalsFocusMode(m model, msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, defaultKeyMap.Enter): cmd := m.approvals.Accept(m.approvals.Selected()) if m.approvals.Count() == 0 { - m.approvalsExpanded = false + m.closeApprovals() } return m, cmd @@ -1138,7 +1146,7 @@ func switchApprovalsFocusMode(m model, msg tea.Msg) (tea.Model, tea.Cmd) { if keyStr == "d" { m.approvals.Dismiss(m.approvals.Selected()) if m.approvals.Count() == 0 { - m.approvalsExpanded = false + m.closeApprovals() } return m, nil } diff --git a/pkg/tui/testdata/TestGolden_ApprovalsExpanded.golden b/pkg/tui/testdata/TestGolden_ApprovalsExpanded.golden new file mode 100644 index 00000000..71ba1a82 --- /dev/null +++ b/pkg/tui/testdata/TestGolden_ApprovalsExpanded.golden @@ -0,0 +1,39 @@ + > Showing assigned to You +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ • ID Summary Service │ +│ • P1234567 Test Alert Firing test-service │ +│ • P7654321 Database CPU High prod-db │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + Watching for updates... [PAUSED] [high urgency only] [AI Watcher] | idle +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Pending Approvals │ +│ │ +│ > [Note] OCM Agent notification failure on test-cluster.example.com │ +│ Post note to incident P1234567 (Test Alert Firing) │ +│ │ +│ OCM Agent has been unable to post ServiceLog notifications for ≄60 minutes. │ +│ This may indicate a permissions issue following cluster ownership transfer. │ +│ │ +│ [Command] Restart ocm-agent pod │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + h helpesc backctrl+q/ctrl+c quit + P1234567 dev - dev \ No newline at end of file diff --git a/pkg/tui/testdata/TestGolden_WatcherOneMarkerAgent.golden b/pkg/tui/testdata/TestGolden_WatcherOneMarkerAgent.golden new file mode 100644 index 00000000..a4940d36 --- /dev/null +++ b/pkg/tui/testdata/TestGolden_WatcherOneMarkerAgent.golden @@ -0,0 +1,39 @@ + > Showing assigned to You +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ • ID Summary Service │ +│ • P1234567 Test Alert Firing test-service │ +│ • P7654321 Database CPU High prod-db │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + Watching for updates... [PAUSED] [high urgency only] [AI Watcher] | idle +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ šŸ¤– Hi! I'm here and watching the incident queue. │ +│ │ +│ Still the same situation on the cluster — waiting on AMS to fix │ +│ the ownership/role issue, then ocm-agent restart. │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + h helpesc backctrl+q/ctrl+c quit + P1234567 dev - dev \ No newline at end of file diff --git a/pkg/tui/testdata/TestGolden_WatcherOneMarkerWatcher.golden b/pkg/tui/testdata/TestGolden_WatcherOneMarkerWatcher.golden new file mode 100644 index 00000000..d0037636 --- /dev/null +++ b/pkg/tui/testdata/TestGolden_WatcherOneMarkerWatcher.golden @@ -0,0 +1,39 @@ + > Showing assigned to You +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ • ID Summary Service │ +│ • P1234567 Test Alert Firing test-service │ +│ • P7654321 Database CPU High prod-db │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + Watching for updates... [PAUSED] [high urgency only] [AI Watcher] | idle +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ šŸ“” **Key Findings:** │ +│ - `etcdMembersDownSRE` is a high-severity control plane alert │ +│ - With ≤3 etcd members, losing even one member threatens quorum │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + h helpesc backctrl+q/ctrl+c quit + P1234567 dev - dev \ No newline at end of file diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index c1959da5..5b2f0b22 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -632,7 +632,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.watcherExpanded = true m.recomputeLayout() } - m.watcherBuffer.Append(prefixLines(m.watcherMarker, "")) + m.watcherBuffer.Append(prefixMessage(m.watcherMarker, "")) m.updateWatcherViewport() return m, readStreamCmd(msg.ch) @@ -644,7 +644,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // now, not at end of stream (idempotent for subsequent chunks). m.aiHealth = aiHealthOK m.watcherStreamPartial += msg.text - m.watcherBuffer.SetLast(prefixLines(m.watcherMarker, m.watcherStreamPartial)) + m.watcherBuffer.SetLast(prefixMessage(m.watcherMarker, m.watcherStreamPartial)) m.updateWatcherViewport() return m, readStreamCmd(msg.ch) @@ -702,7 +702,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if msg.err != nil { m.aiHealth = aiHealthError - m.watcherBuffer.Append(prefixLines(m.watcherMarker, msg.observation)) + m.watcherBuffer.Append(prefixMessage(m.watcherMarker, msg.observation)) m.updateWatcherViewport() return m, nil } @@ -715,7 +715,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.err != nil { log.Debug("investigation", "error", msg.err) log.Warn("investigation", "error", ai.ClassifyProviderError(msg.err)) - m.watcherBuffer.Append(prefixLines(m.watcherMarker, msg.observation)) + m.watcherBuffer.Append(prefixMessage(m.watcherMarker, msg.observation)) m.updateWatcherViewport() return m, nil } @@ -1202,11 +1202,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { serviceName = serviceName + suffix } } - title := i.Title + title := stripControl(i.Title) if matchedFlags, ok := m.flagMatchCache[i.ID]; ok && len(matchedFlags) > 0 { title = m.flagMarker + title } - rows = append(rows, table.Row{state, i.ID, title, serviceName}) + rows = append(rows, table.Row{state, i.ID, title, stripControl(serviceName)}) } } @@ -1893,7 +1893,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.watcherExpanded = true m.recomputeLayout() } - m.watcherBuffer.Append(prefixLines(m.agentMarker, "")) + m.watcherBuffer.Append(prefixMessage(m.agentMarker, "")) m.updateWatcherViewport() return m, readAgentStreamCmd(msg.ch) @@ -1902,7 +1902,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.agentStreamPartial += msg.text - m.watcherBuffer.SetLast(prefixLines(m.agentMarker, m.agentStreamPartial)) + m.watcherBuffer.SetLast(prefixMessage(m.agentMarker, m.agentStreamPartial)) m.updateWatcherViewport() return m, readAgentStreamCmd(msg.ch) diff --git a/pkg/tui/view_render_test.go b/pkg/tui/view_render_test.go index b3e425b9..5e04f307 100644 --- a/pkg/tui/view_render_test.go +++ b/pkg/tui/view_render_test.go @@ -302,7 +302,7 @@ func TestView_ChatModeRendersExpectedContent(t *testing.T) { m.chatInput.Prompt = " > " m.chatInput.Focus() m.watcherBuffer = newWatcherBuffer(50) - m.watcherBuffer.Append(prefixLines(m.agentMarker, "Agent response text")) + m.watcherBuffer.Append(prefixMessage(m.agentMarker, "Agent response text")) m.recomputeLayout() m.updateChatViewport() m.chatViewportGotoBottom() diff --git a/pkg/tui/views.go b/pkg/tui/views.go index f8637820..fa761792 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -118,7 +118,11 @@ func (m model) View() string { s.WriteString("\n") s.WriteString(m.renderFooter()) s.WriteString("\n") - s.WriteString(m.renderWatcherPane()) + if m.approvalsExpanded { + s.WriteString(m.renderApprovalsPane()) + } else { + s.WriteString(m.renderWatcherPane()) + } if m.input.Focused() { s.WriteString(m.input.View()) } else { @@ -169,14 +173,10 @@ func (m model) View() string { s.WriteString(helpView) s.WriteString("\n") - // Add approvals strip above bottom status when asks are pending - if m.approvals != nil && m.approvals.Count() > 0 { - if m.approvalsExpanded { - s.WriteString(m.approvals.RenderExpanded(m.layout.ContentWidth)) - } else { - s.WriteString(m.approvals.Render(m.layout.ContentWidth)) - s.WriteString("\n") - } + // Add collapsed approvals badge above bottom status when asks are pending + if m.approvals != nil && m.approvals.Count() > 0 && !m.approvalsExpanded { + s.WriteString(m.approvals.Render(m.layout.ContentWidth)) + s.WriteString("\n") } // Add bottom status line at terminal bottom @@ -925,8 +925,8 @@ func summarizeNotes(n []pagerduty.IncidentNote) []noteSummary { for _, note := range n { s = append(s, noteSummary{ ID: note.ID, - User: note.User.Summary, - Content: note.Content, + User: stripControl(note.User.Summary), + Content: stripControl(note.Content), Created: note.CreatedAt, }) } @@ -991,20 +991,20 @@ func summarizeAlerts(a []pagerduty.IncidentAlert, clusterCache map[string]*ocm.C s = append(s, alertSummary{ ID: alt.ID, - Name: name, + Name: stripControl(name), Link: link, Cluster: cluster, - ClusterName: clusterName, + ClusterName: stripControl(clusterName), HTMLURL: alt.HTMLURL, - Service: alt.Service.Summary, + Service: stripControl(alt.Service.Summary), Created: alt.CreatedAt, Status: alt.Status, Incident: alt.Incident.ID, - Severity: normalized.Severity, + Severity: stripControl(normalized.Severity), Tags: normalized.Tags, - AlertType: normalized.AlertType, - Namespace: normalized.Namespace, - Description: normalized.Description, + AlertType: stripControl(normalized.AlertType), + Namespace: stripControl(normalized.Namespace), + Description: stripControl(normalized.Description), }) } @@ -1037,23 +1037,23 @@ func summarizeIncident(i *pagerduty.Incident) incidentSummary { var s incidentSummary s.ID = i.ID - s.Title = i.Title + s.Title = stripControl(i.Title) s.HTMLURL = i.HTMLURL - s.Service = i.Service.Summary - s.EscalationPolicy = i.EscalationPolicy.Summary + s.Service = stripControl(i.Service.Summary) + s.EscalationPolicy = stripControl(i.EscalationPolicy.Summary) s.Created = i.CreatedAt s.Urgency = i.Urgency s.Status = i.Status if i.Priority != nil { - s.Priority = i.Priority.Summary + s.Priority = stripControl(i.Priority.Summary) } for _, team := range i.Teams { - s.Teams = append(s.Teams, team.Summary) + s.Teams = append(s.Teams, stripControl(team.Summary)) } for _, asn := range i.Assignments { - s.Assigned = append(s.Assigned, asn.Assignee.Summary) + s.Assigned = append(s.Assigned, stripControl(asn.Assignee.Summary)) } for _, ack := range i.Acknowledgements { @@ -1350,6 +1350,18 @@ func (m model) renderChatPane() string { return s } +func (m model) renderApprovalsPane() string { + content := m.approvals.RenderExpanded(m.layout.WatcherWidth) + if m.layout.WatcherHeight > 0 { + lines := strings.Split(content, "\n") + if len(lines) > m.layout.WatcherHeight { + lines = lines[:m.layout.WatcherHeight] + } + content = strings.Join(lines, "\n") + } + return m.styles.WatcherContainer.Render(content) + "\n" +} + func (m model) renderWatcherPane() string { if !m.watcherExpanded { return "" diff --git a/pkg/tui/watcher.go b/pkg/tui/watcher.go index a72b6ac8..6724e11e 100644 --- a/pkg/tui/watcher.go +++ b/pkg/tui/watcher.go @@ -6,10 +6,13 @@ import ( "strings" "time" + "charm.land/glamour/v2" "github.com/PagerDuty/go-pagerduty" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/log" + "github.com/muesli/termenv" + "github.com/clcollins/srepd/pkg/ai" "github.com/clcollins/srepd/pkg/delta" ) @@ -83,11 +86,48 @@ func (b *watcherBuffer) Clear() { b.entries = b.entries[:0] } -func (m *model) updateWatcherViewport() { - content := m.watcherBuffer.Content() - if m.watcherViewport.Width > 0 { - content = lipgloss.NewStyle().Width(m.watcherViewport.Width).Render(content) +func (m *model) renderWatcherMarkdown(content string, width int) string { + if width < 10 { + width = 80 + } + if lipgloss.ColorProfile() == termenv.Ascii { + return lipgloss.NewStyle().Width(width).Render(content) + } + if m.markdownRenderer == nil || m.watcherRendererWidth != width { + renderer, err := glamour.NewTermRenderer( + glamour.WithStyles(m.styles.GlamourStyle), + glamour.WithWordWrap(width), + ) + if err != nil { + return lipgloss.NewStyle().Width(width).Render(content) + } + m.markdownRenderer = renderer + m.watcherRendererWidth = width + } + rendered, err := m.markdownRenderer.Render(content) + if err != nil { + return lipgloss.NewStyle().Width(width).Render(content) + } + return strings.TrimRight(rendered, "\n") +} + +func (m *model) renderWatcherEntries(width int) string { + entries := m.watcherBuffer.entries + if len(entries) == 0 { + return "" } + if width <= 0 { + return strings.Join(entries, "\n───\n") + } + rendered := make([]string, len(entries)) + for i, entry := range entries { + rendered[i] = m.renderWatcherMarkdown(entry, width) + } + return strings.Join(rendered, "\n───\n") +} + +func (m *model) updateWatcherViewport() { + content := m.renderWatcherEntries(m.watcherViewport.Width) m.watcherViewport.SetContent(content) m.watcherViewport.GotoBottom() @@ -97,10 +137,7 @@ func (m *model) updateWatcherViewport() { } func (m *model) updateChatViewport() { - content := m.watcherBuffer.Content() - if m.chatViewport.Width > 0 { - content = lipgloss.NewStyle().Width(m.chatViewport.Width).Render(content) - } + content := m.renderWatcherEntries(m.chatViewport.Width) wasAtBottom := m.chatViewport.AtBottom() m.chatViewport.SetContent(content) if wasAtBottom { @@ -176,7 +213,7 @@ func (m *model) advanceTypewriter() tea.Cmd { } tw.index = end - m.watcherBuffer.SetLast(prefixLines(tw.marker, tw.partial)) + m.watcherBuffer.SetLast(prefixMessage(tw.marker, tw.partial)) m.updateWatcherViewport() if tw.index >= len(tw.words) { @@ -282,7 +319,7 @@ func (m *model) runDetectors(changes []delta.Change) []tea.Cmd { cmds = append(cmds, watcherSynthesizeCmd(m.aiProvider, m.watcherSystemPrompt, obs.Summary, summary)) } } else { - m.watcherBuffer.Append(prefixLines(m.watcherMarker, obs.Summary)) + m.watcherBuffer.Append(prefixMessage(m.watcherMarker, obs.Summary)) added = true } } @@ -662,15 +699,12 @@ func stripControl(s string) string { return b.String() } -func prefixLines(marker string, text string) string { - lines := strings.Split(text, "\n") - var result []string - for _, line := range lines { - if strings.TrimSpace(line) == "" { - result = append(result, "") - } else { - result = append(result, marker+line) - } +// prefixMessage prepends the marker exactly once at the start of the block. +// Continuation lines get NO marker — the user sees one identifier per +// watcher/agent response. +func prefixMessage(marker string, text string) string { + if text == "" { + return "" } - return strings.Join(result, "\n") + return marker + text } diff --git a/pkg/tui/watcher_integration_test.go b/pkg/tui/watcher_integration_test.go index 5470a4e5..1d854aa7 100644 --- a/pkg/tui/watcher_integration_test.go +++ b/pkg/tui/watcher_integration_test.go @@ -630,7 +630,7 @@ func TestWatcherPromptMsg_StreamingDisabled_FallsBackToBlocking(t *testing.T) { func TestWatcherStreamChunkMsg_AccumulatesInPlace(t *testing.T) { m := createTestModel() windowSize = tea.WindowSizeMsg{Width: 80, Height: 60} - m.watcherBuffer.Append(prefixLines(m.watcherMarker, "")) + m.watcherBuffer.Append(prefixMessage(m.watcherMarker, "")) ch := make(chan streamEvent) m.watcherStreamCh = ch @@ -993,7 +993,7 @@ func TestWatcherStreamChunkMsg_FirstTokenSetsHealthOK(t *testing.T) { m := createTestModel() windowSize = tea.WindowSizeMsg{Width: 80, Height: 60} m.aiHealth = aiHealthUnverified - m.watcherBuffer.Append(prefixLines(m.watcherMarker, "")) + m.watcherBuffer.Append(prefixMessage(m.watcherMarker, "")) ch := make(chan streamEvent) m.watcherStreamCh = ch diff --git a/pkg/tui/watcher_rendering_test.go b/pkg/tui/watcher_rendering_test.go new file mode 100644 index 00000000..d2228012 --- /dev/null +++ b/pkg/tui/watcher_rendering_test.go @@ -0,0 +1,252 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// F2 tests: exactly one marker per --- block + +func TestPrefixMessage_OneMarkerPerBlock(t *testing.T) { + t.Run("single line gets one marker", func(t *testing.T) { + result := prefixMessage("šŸ“” ", "hello world") + assert.Equal(t, "šŸ“” hello world", result) + }) + + t.Run("multi-line block gets exactly one marker at start", func(t *testing.T) { + text := "line one\nline two\nline three" + result := prefixMessage("šŸ“” ", text) + + lines := strings.Split(result, "\n") + markerCount := 0 + for _, line := range lines { + if strings.HasPrefix(line, "šŸ“” ") { + markerCount++ + } + } + assert.Equal(t, 1, markerCount, + "multi-line block must have exactly ONE marker, got %d in: %q", markerCount, result) + assert.True(t, strings.HasPrefix(result, "šŸ“” "), + "marker must be at the start of the block") + }) + + t.Run("agent marker multi-line gets one marker", func(t *testing.T) { + text := "Hi! I'm watching.\n\nStill the same situation.\nWaiting on AMS." + result := prefixMessage("šŸ¤– ", text) + + lines := strings.Split(result, "\n") + markerCount := 0 + for _, line := range lines { + if strings.HasPrefix(line, "šŸ¤– ") { + markerCount++ + } + } + assert.Equal(t, 1, markerCount, + "agent block must have exactly ONE marker, got %d in: %q", markerCount, result) + }) + + t.Run("markdown content gets one marker", func(t *testing.T) { + text := "**Key Findings:**\n- `etcdMembersDownSRE` is critical\n- With ≤3 etcd members, quorum threatened" + result := prefixMessage("šŸ“” ", text) + + markerCount := strings.Count(result, "šŸ“” ") + assert.Equal(t, 1, markerCount, + "markdown block must have exactly ONE marker, got %d", markerCount) + }) + + t.Run("empty string returns empty", func(t *testing.T) { + result := prefixMessage("šŸ“” ", "") + assert.Equal(t, "", result) + }) + + t.Run("no-emoji markers also get one per block", func(t *testing.T) { + text := "first line\nsecond line\nthird line" + result := prefixMessage("☻ ", text) + + markerCount := strings.Count(result, "☻ ") + assert.Equal(t, 1, markerCount) + }) +} + +func TestWatcherBuffer_OneMarkerPerBlock_Integration(t *testing.T) { + buf := newWatcherBuffer(50) + + buf.Append(prefixMessage("šŸ“” ", "First verdict\nwith details")) + buf.Append(prefixMessage("šŸ¤– ", "Agent response\n\nWith blank lines\nand more text")) + buf.Append(prefixMessage("šŸ“” ", "Second verdict")) + + content := buf.Content() + blocks := strings.Split(content, "\n---\n") + require.Len(t, blocks, 3) + + watcherCount := strings.Count(blocks[0], "šŸ“” ") + assert.Equal(t, 1, watcherCount, "first block must have exactly one watcher marker") + + agentCount := strings.Count(blocks[1], "šŸ¤– ") + assert.Equal(t, 1, agentCount, "second block must have exactly one agent marker") + + watcherCount2 := strings.Count(blocks[2], "šŸ“” ") + assert.Equal(t, 1, watcherCount2, "third block must have exactly one watcher marker") +} + +func TestWatcherBuffer_StreamingSetLast_OneMarker(t *testing.T) { + buf := newWatcherBuffer(50) + + // Simulate streaming: Append empty, then SetLast with growing partial + buf.Append(prefixMessage("šŸ¤– ", "")) + buf.SetLast(prefixMessage("šŸ¤– ", "Hello")) + buf.SetLast(prefixMessage("šŸ¤– ", "Hello world")) + buf.SetLast(prefixMessage("šŸ¤– ", "Hello world\nSecond line")) + buf.SetLast(prefixMessage("šŸ¤– ", "Hello world\nSecond line\nThird line")) + + content := buf.Content() + markerCount := strings.Count(content, "šŸ¤– ") + assert.Equal(t, 1, markerCount, + "streaming block must have exactly ONE marker after multiple SetLast calls, got %d in:\n%s", + markerCount, content) +} + +// F1 tests: approvals render in watcher pane slot + +func TestView_ApprovalsExpandedRendersInWatcherSlot(t *testing.T) { + m := sizedTestModel(t) + m.approvals = newApprovalsStrip() + m.approvals.Add(Ask{ + Kind: AskDraftNote, + Title: "OCM Agent notification failure on test-cluster.example.com", + Body: "OCM Agent has been unable to post ServiceLog notifications for ≄60 minutes", + IncidentID: "P1234567", + IncidentTitle: "Test Alert Firing", + }) + m.approvalsExpanded = true + m.watcherExpanded = true + m.recomputeLayout() + + view := m.View() + + // Approvals should be visible in the content area (watcher slot) + assert.Contains(t, view, "Pending Approvals", "approvals header must be visible") + assert.Contains(t, view, "OCM Agent notification failure", "ask title must be visible") + + // Body must be rendered + assert.Contains(t, view, "OCM Agent has been unable to post", "ask body must be rendered") + + // Action target line must show incident info + assert.Contains(t, view, "P1234567", "incident ID must be shown per ask") +} + +func TestView_ApprovalsExpandedWrapsLongTitles(t *testing.T) { + m := sizedTestModel(t) + m.approvals = newApprovalsStrip() + longTitle := "OCM Agent has been unable to post ServiceLog notifications for ≄60 minutes on cluster rhos-ota.ynna.p1.example.com which is very long" + m.approvals.Add(Ask{ + Kind: AskDraftNote, + Title: longTitle, + Body: "Detailed body text", + IncidentID: "P1234567", + IncidentTitle: "Test Alert", + }) + m.approvalsExpanded = true + m.watcherExpanded = true + m.recomputeLayout() + + view := m.View() + + // No line should exceed terminal width + for i, line := range strings.Split(view, "\n") { + w := lipgloss.Width(line) + assert.LessOrEqual(t, w, 120, + "line %d exceeds terminal width: %d cols: %q", i, w, line) + } +} + +func TestView_ApprovalsCollapsedShowsBadge(t *testing.T) { + m := sizedTestModel(t) + m.approvals = newApprovalsStrip() + m.approvals.Add(Ask{Kind: AskDraftNote, Title: "Test"}) + m.approvalsExpanded = false + + view := m.View() + + assert.Contains(t, view, "āš‘", "collapsed badge must show flag marker") + assert.Contains(t, view, "1 ask", "collapsed badge must show count") + assert.Contains(t, view, "press A", "collapsed badge must show key hint") +} + +func TestView_ApprovalsRestoredWatcherStateOnClose(t *testing.T) { + m := sizedTestModel(t) + m.approvals = newApprovalsStrip() + m.approvals.Add(Ask{Kind: AskDraftNote, Title: "Test"}) + m.watcherExpanded = true + m.approvalsExpanded = true + m.watcherWasExpanded = true + + // Simulate closing approvals via Escape + result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m2 := result.(model) + + assert.False(t, m2.approvalsExpanded, "approvals must close on Escape") + assert.True(t, m2.watcherExpanded, "watcher must be restored to prior expanded state") +} + +// F3 tests: markdown rendering in watcher pane + +func TestUpdateWatcherViewport_RendersMarkdown(t *testing.T) { + // Glamour skips rendering in Ascii mode (golden test safety guard), + // so temporarily enable TrueColor to exercise the glamour path. + lipgloss.SetColorProfile(termenv.TrueColor) + defer lipgloss.SetColorProfile(termenv.Ascii) + + m := sizedTestModel(t) + m.watcherExpanded = true + m.watcherBuffer = newWatcherBuffer(50) + m.recomputeLayout() + + m.watcherBuffer.Append(prefixMessage("šŸ“” ", "**Key Findings:**\n- `etcdMembersDownSRE` is critical\n- Quorum threatened")) + m.updateWatcherViewport() + + content := m.watcherViewport.View() + + assert.NotContains(t, content, "**Key Findings:**", + "raw bold markdown must not appear after glamour rendering") +} + +func TestRenderApprovalsExpanded_ShowsBody(t *testing.T) { + strip := newApprovalsStrip() + strip.Add(Ask{ + Kind: AskDraftNote, + Title: "Post note to incident", + Body: "The cluster etcd members are failing health checks", + IncidentID: "P1234567", + IncidentTitle: "etcdMembersDownSRE on test-cluster", + }) + + rendered := strip.RenderExpanded(80) + + assert.Contains(t, rendered, "Pending Approvals", "header must be present") + assert.Contains(t, rendered, "Post note to incident", "title must be present") + assert.Contains(t, rendered, "etcd members are failing", "body must be rendered") + assert.Contains(t, rendered, "P1234567", "incident ID must be shown") +} + +func TestRenderApprovalsExpanded_ActionTargetLine(t *testing.T) { + strip := newApprovalsStrip() + strip.Add(Ask{ + Kind: AskDraftNote, + Title: "Investigation findings", + Body: "Cluster shows elevated error rate", + IncidentID: "P9999999", + IncidentTitle: "CPU High on prod-cluster", + }) + + rendered := strip.RenderExpanded(100) + + assert.Contains(t, rendered, "P9999999", "incident ID must appear in action target line") + assert.Contains(t, rendered, "Note", "ask kind must appear") +} diff --git a/pkg/tui/watcher_test.go b/pkg/tui/watcher_test.go index 3fbed34c..570dd1c6 100644 --- a/pkg/tui/watcher_test.go +++ b/pkg/tui/watcher_test.go @@ -67,53 +67,6 @@ func TestWatcherBuffer_EmptyContent(t *testing.T) { assert.Equal(t, 0, buf.Len()) } -func TestPrefixLines(t *testing.T) { - tests := []struct { - name string - marker string - text string - expected string - }{ - { - name: "single line", - marker: "šŸ¤– ", - text: "hello", - expected: "šŸ¤– hello", - }, - { - name: "multi line", - marker: "šŸ¤– ", - text: "line one\nline two\nline three", - expected: "šŸ¤– line one\nšŸ¤– line two\nšŸ¤– line three", - }, - { - name: "blank lines preserved without marker", - marker: "šŸ“” ", - text: "first\n\nsecond\n\nthird", - expected: "šŸ“” first\n\nšŸ“” second\n\nšŸ“” third", - }, - { - name: "whitespace-only lines treated as blank", - marker: "☻ ", - text: "hello\n \nworld", - expected: "☻ hello\n\n☻ world", - }, - { - name: "empty string", - marker: "šŸ¤– ", - text: "", - expected: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := prefixLines(tt.marker, tt.text) - assert.Equal(t, tt.expected, result) - }) - } -} - func TestResolveMarkers_Emoji(t *testing.T) { mk := resolveMarkers(true) assert.Equal(t, emojiFlagMarker, mk.flag)