| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/charmbracelet/colorprofile" |
| 9 | |
| 10 | "github.com/charmbracelet/x/ansi" |
| 11 | |
| 12 | "reasonix/internal/provider" |
| 13 | ) |
| 14 | |
| 15 | func TestAssistantMarkdownHasIdentityAndIndentedBody(t *testing.T) { |
| 16 | defer restoreThemeForTest(activeColorProfile, activeCLITheme) |
| 17 | activeColorProfile = colorprofile.NoTTY |
| 18 | configureCLITheme("dark") |
| 19 | |
| 20 | rendered := renderAssistantMarkdown("A concise answer that wraps across the available width.", 32) |
| 21 | lines := strings.Split(ansi.Strip(rendered), "\n") |
| 22 | if len(lines) < 4 { |
| 23 | t.Fatalf("assistant block should contain a header, gap, and wrapped body:\n%s", rendered) |
| 24 | } |
| 25 | if lines[0] != " ◆ Reasonix" { |
| 26 | t.Fatalf("assistant header = %q, want %q", lines[0], " ◆ Reasonix") |
| 27 | } |
| 28 | if lines[1] != "" { |
| 29 | t.Fatalf("assistant header/body separator = %q, want blank row", lines[1]) |
| 30 | } |
| 31 | for i, line := range lines[2:] { |
| 32 | if line != "" && !strings.HasPrefix(line, assistantTranscriptIndent) { |
| 33 | t.Fatalf("assistant body row %d lacks the two-cell gutter: %q", i+2, line) |
| 34 | } |
| 35 | if width := visibleWidth(line); width > 32 { |
| 36 | t.Fatalf("assistant row %d width = %d, want <= 32: %q", i+2, width, line) |
| 37 | } |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | func TestReplaySectionsKeepAssistantIdentity(t *testing.T) { |
| 42 | defer restoreThemeForTest(activeColorProfile, activeCLITheme) |
| 43 | activeColorProfile = colorprofile.NoTTY |
| 44 | configureCLITheme("dark") |
| 45 | |
| 46 | sections := replaySectionsFor([]provider.Message{ |
| 47 | {Role: provider.RoleUser, Content: "Which version?"}, |
| 48 | {Role: provider.RoleAssistant, Content: "Version 1.2.3"}, |
| 49 | }, 48) |
| 50 | if len(sections) != 2 { |
| 51 | t.Fatalf("replay sections = %d, want user and assistant", len(sections)) |
| 52 | } |
| 53 | if plain := ansi.Strip(sections[1]); !strings.HasPrefix(plain, " ◆ Reasonix\n\n Version 1.2.3") { |
| 54 | t.Fatalf("replayed assistant answer lost its identity: %q", plain) |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | func TestReplaySectionsRestoreInterruptedLocalOutput(t *testing.T) { |
| 59 | defer restoreThemeForTest(activeColorProfile, activeCLITheme) |
| 60 | activeColorProfile = colorprofile.NoTTY |
| 61 | configureCLITheme("dark") |
| 62 | |
| 63 | sections := replaySectionsFor([]provider.Message{ |
| 64 | {Role: provider.RoleUser, Content: "change config"}, |
| 65 | { |
| 66 | Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName, |
| 67 | LocalOnly: true, Content: "partial answer", ReasoningContent: "checking config", |
| 68 | ToolCalls: []provider.ToolCall{{ID: "p1", Name: "write_file"}}, |
| 69 | InterruptedTurn: &provider.InterruptedTurnRecovery{Pending: true}, |
| 70 | }, |
| 71 | }, 64) |
| 72 | plain := ansi.Strip(strings.Join(sections, "")) |
| 73 | for _, want := range []string{"change config", "checking config", "partial answer", "Write", "bounded recovery summary"} { |
| 74 | if !strings.Contains(plain, want) { |
| 75 | t.Fatalf("replayed interrupted history missing %q:\n%s", want, plain) |
| 76 | } |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | func TestScrollbarThumb(t *testing.T) { |
| 81 | if _, size := scrollbarThumb(10, 0, 5); size != 0 { |
| 82 | t.Errorf("content within viewport should have no thumb, got size %d", size) |
| 83 | } |
| 84 | if start, _ := scrollbarThumb(10, 0, 100); start != 0 { |
| 85 | t.Errorf("at top the thumb starts at row 0, got %d", start) |
| 86 | } |
| 87 | const h, total = 10, 100 |
| 88 | if start, size := scrollbarThumb(h, total-h, total); start+size != h { |
| 89 | t.Errorf("at bottom the thumb reaches the last row: start=%d size=%d h=%d", start, size, h) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | func TestEdgeScrollDir(t *testing.T) { |
| 94 | const h = 10 |
| 95 | if got := edgeScrollDir(0, h); got != -1 { |
| 96 | t.Errorf("top edge dir = %d, want -1", got) |
| 97 | } |
| 98 | if got := edgeScrollDir(h-1, h); got != 1 { |
| 99 | t.Errorf("bottom edge dir = %d, want 1", got) |
| 100 | } |
| 101 | if got := edgeScrollDir(h/2, h); got != 0 { |
| 102 | t.Errorf("middle dir = %d, want 0", got) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | func TestSelSpan(t *testing.T) { |
| 107 | start, end, cw := selPos{line: 1, col: 3}, selPos{line: 3, col: 5}, 20 |
| 108 | for _, tc := range []struct { |
| 109 | idx int |
| 110 | wantOK bool |
| 111 | wantLo, wHi int |
| 112 | }{ |
| 113 | {0, false, 0, 0}, // above |
| 114 | {1, true, 3, cw}, // first line: anchor col → right edge |
| 115 | {2, true, 0, cw}, // middle line: full width |
| 116 | {3, true, 0, 5}, // last line: left edge → head col |
| 117 | {4, false, 0, 0}, // below |
| 118 | } { |
| 119 | lo, hi, ok := selSpan(tc.idx, start, end, cw) |
| 120 | if ok != tc.wantOK || (ok && (lo != tc.wantLo || hi != tc.wHi)) { |
| 121 | t.Errorf("selSpan(%d) = (%d,%d,%v), want (%d,%d,%v)", tc.idx, lo, hi, ok, tc.wantLo, tc.wHi, tc.wantOK) |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func TestSelectedTextMultiLine(t *testing.T) { |
| 127 | m := newTestChatTUI() |
| 128 | m.wrappedLines = []string{"hello world", "second line", "third row"} |
| 129 | m.sel = selection{active: true, anchor: selPos{line: 0, col: 6}, head: selPos{line: 2, col: 5}} |
| 130 | |
| 131 | if got, want := m.selectedText(), "world\nsecond line\nthird"; got != want { |
| 132 | t.Errorf("selectedText() = %q, want %q", got, want) |
| 133 | } |
| 134 | |
| 135 | // A zero-width selection (plain click) copies nothing. |
| 136 | m.sel = selection{active: true, anchor: selPos{line: 0, col: 3}, head: selPos{line: 0, col: 3}} |
| 137 | if got := m.selectedText(); got != "" { |
| 138 | t.Errorf("empty selection should yield no text, got %q", got) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func TestSelectedTextRestoresMathWithoutReusingRawColumns(t *testing.T) { |
| 143 | defer restoreThemeForTest(activeColorProfile, activeCLITheme) |
| 144 | activeColorProfile = colorprofile.NoTTY |
| 145 | configureCLITheme("dark") |
| 146 | |
| 147 | m := newTestChatTUI() |
| 148 | m.width = 80 |
| 149 | contentWidth := transcriptContentWidth(m.width, m.nativeScrollback) |
| 150 | m.viewport.SetWidth(contentWidth) |
| 151 | source := transcriptSource{kind: transcriptSourceMarkdown, raw: `before $\alpha$ after`} |
| 152 | rendered := m.renderTranscriptSource(source, m.width) |
| 153 | m.transcript = []string{rendered} |
| 154 | m.transcriptSources = []transcriptSource{source} |
| 155 | m.wrappedLines = strings.Split(wrapTranscript(rendered, contentWidth), "\n") |
| 156 | |
| 157 | lineIndex := -1 |
| 158 | for i, line := range m.wrappedLines { |
| 159 | if strings.Contains(ansi.Strip(line), "before α after") { |
| 160 | lineIndex = i |
| 161 | break |
| 162 | } |
| 163 | } |
| 164 | if lineIndex < 0 { |
| 165 | t.Fatalf("rendered transcript did not contain the math line:\n%s", ansi.Strip(rendered)) |
| 166 | } |
| 167 | |
| 168 | plain := ansi.Strip(m.wrappedLines[lineIndex]) |
| 169 | formulaByte := strings.Index(plain, "α") |
| 170 | afterByte := strings.Index(plain, "after") |
| 171 | if formulaByte < 0 || afterByte < 0 { |
| 172 | t.Fatalf("math line = %q", plain) |
| 173 | } |
| 174 | formulaCol := ansi.StringWidth(plain[:formulaByte]) |
| 175 | afterCol := ansi.StringWidth(plain[:afterByte]) |
| 176 | |
| 177 | m.sel = selection{ |
| 178 | active: true, |
| 179 | anchor: selPos{line: lineIndex, col: formulaCol}, |
| 180 | head: selPos{line: lineIndex, col: formulaCol + ansi.StringWidth("α")}, |
| 181 | } |
| 182 | if got, want := m.selectedText(), `$\alpha$`; got != want { |
| 183 | t.Fatalf("formula selection = %q, want %q", got, want) |
| 184 | } |
| 185 | |
| 186 | m.sel = selection{ |
| 187 | active: true, |
| 188 | anchor: selPos{line: lineIndex, col: afterCol}, |
| 189 | head: selPos{line: lineIndex, col: afterCol + ansi.StringWidth("after")}, |
| 190 | } |
| 191 | if got, want := m.selectedText(), "after"; got != want { |
| 192 | t.Fatalf("text after formula = %q, want %q", got, want) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | func TestSelectedTextRestoresMathFromReplayBundle(t *testing.T) { |
| 197 | defer restoreThemeForTest(activeColorProfile, activeCLITheme) |
| 198 | activeColorProfile = colorprofile.NoTTY |
| 199 | configureCLITheme("dark") |
| 200 | |
| 201 | m := newTestChatTUI() |
| 202 | m.width = 80 |
| 203 | contentWidth := transcriptContentWidth(m.width, m.nativeScrollback) |
| 204 | m.viewport.SetWidth(contentWidth) |
| 205 | source := transcriptSource{ |
| 206 | kind: transcriptSourceReplayBundle, |
| 207 | history: []provider.Message{ |
| 208 | {Role: provider.RoleAssistant, Content: `before $\alpha$ after`}, |
| 209 | {LocalOnly: true, Content: `local $\beta$ recovery`}, |
| 210 | }, |
| 211 | } |
| 212 | rendered := m.renderTranscriptSource(source, m.width) |
| 213 | m.transcript = []string{rendered} |
| 214 | m.transcriptSources = []transcriptSource{source} |
| 215 | m.wrappedLines = strings.Split(wrapTranscript(rendered, contentWidth), "\n") |
| 216 | |
| 217 | lineIndex := -1 |
| 218 | formulaCol := -1 |
| 219 | for i, line := range m.wrappedLines { |
| 220 | plain := ansi.Strip(line) |
| 221 | formulaByte := strings.Index(plain, "α") |
| 222 | if formulaByte < 0 { |
| 223 | continue |
| 224 | } |
| 225 | lineIndex = i |
| 226 | formulaCol = ansi.StringWidth(plain[:formulaByte]) |
| 227 | break |
| 228 | } |
| 229 | if lineIndex < 0 { |
| 230 | t.Fatalf("rendered replay bundle did not contain the formula:\n%s", ansi.Strip(rendered)) |
| 231 | } |
| 232 | |
| 233 | m.sel = selection{ |
| 234 | active: true, |
| 235 | anchor: selPos{line: lineIndex, col: formulaCol}, |
| 236 | head: selPos{line: lineIndex, col: formulaCol + ansi.StringWidth("α")}, |
| 237 | } |
| 238 | if got, want := m.selectedText(), `$\alpha$`; got != want { |
| 239 | t.Fatalf("replayed formula selection = %q, want %q", got, want) |
| 240 | } |
| 241 | |
| 242 | copyLines, ok := m.copyTranscriptLines() |
| 243 | if !ok { |
| 244 | t.Fatal("copy rendition diverged from the displayed replay bundle") |
| 245 | } |
| 246 | sourcesByID := make(map[string]string) |
| 247 | for _, line := range copyLines { |
| 248 | for _, span := range line.math { |
| 249 | if source, exists := sourcesByID[span.id]; exists && source != span.source { |
| 250 | t.Fatalf("formula marker %q reused for %q and %q", span.id, source, span.source) |
| 251 | } |
| 252 | sourcesByID[span.id] = span.source |
| 253 | } |
| 254 | } |
| 255 | if len(sourcesByID) != 2 { |
| 256 | t.Fatalf("replay formula markers = %v, want two unique formulas", sourcesByID) |
| 257 | } |
| 258 | foundSources := make(map[string]bool) |
| 259 | for _, source := range sourcesByID { |
| 260 | foundSources[source] = true |
| 261 | } |
| 262 | for _, want := range []string{`$\alpha$`, `$\beta$`} { |
| 263 | if !foundSources[want] { |
| 264 | t.Fatalf("replay formula markers = %v, missing %q", sourcesByID, want) |
| 265 | } |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | func TestSelectedTextPreservesProseAroundMath(t *testing.T) { |
| 270 | defer restoreThemeForTest(activeColorProfile, activeCLITheme) |
| 271 | activeColorProfile = colorprofile.NoTTY |
| 272 | configureCLITheme("dark") |
| 273 | |
| 274 | m := newTestChatTUI() |
| 275 | m.width = 80 |
| 276 | contentWidth := transcriptContentWidth(m.width, m.nativeScrollback) |
| 277 | m.viewport.SetWidth(contentWidth) |
| 278 | source := transcriptSource{kind: transcriptSourceMarkdown, raw: `before $\frac{1}{2}$ after`} |
| 279 | rendered := m.renderTranscriptSource(source, m.width) |
| 280 | m.transcript = []string{rendered} |
| 281 | m.transcriptSources = []transcriptSource{source} |
| 282 | m.wrappedLines = strings.Split(wrapTranscript(rendered, contentWidth), "\n") |
| 283 | |
| 284 | for i, line := range m.wrappedLines { |
| 285 | plain := ansi.Strip(line) |
| 286 | startByte := strings.Index(plain, "before") |
| 287 | endByte := strings.Index(plain, " after") |
| 288 | if startByte < 0 || endByte < 0 { |
| 289 | continue |
| 290 | } |
| 291 | startCol := ansi.StringWidth(plain[:startByte]) |
| 292 | endCol := ansi.StringWidth(plain[:endByte+len(" after")]) |
| 293 | m.sel = selection{ |
| 294 | active: true, |
| 295 | anchor: selPos{line: i, col: startCol}, |
| 296 | head: selPos{line: i, col: endCol}, |
| 297 | } |
| 298 | if got, want := m.selectedText(), `before $\frac{1}{2}$ after`; got != want { |
| 299 | t.Fatalf("mixed selection = %q, want %q", got, want) |
| 300 | } |
| 301 | return |
| 302 | } |
| 303 | t.Fatalf("rendered transcript did not contain the expected mixed line:\n%s", ansi.Strip(rendered)) |
| 304 | } |
| 305 | |
| 306 | func TestSelectedTextRestoresMathWrappedAcrossDisplayLinesOnce(t *testing.T) { |
| 307 | defer restoreThemeForTest(activeColorProfile, activeCLITheme) |
| 308 | activeColorProfile = colorprofile.NoTTY |
| 309 | configureCLITheme("dark") |
| 310 | |
| 311 | m := newTestChatTUI() |
| 312 | m.width = 10 |
| 313 | contentWidth := transcriptContentWidth(m.width, m.nativeScrollback) |
| 314 | m.viewport.SetWidth(contentWidth) |
| 315 | const latex = `\alpha+\beta+\gamma+\delta+\epsilon+\zeta` |
| 316 | source := transcriptSource{kind: transcriptSourceMarkdown, raw: `$` + latex + `$`} |
| 317 | rendered := m.renderTranscriptSource(source, m.width) |
| 318 | m.transcript = []string{rendered} |
| 319 | m.transcriptSources = []transcriptSource{source} |
| 320 | m.wrappedLines = strings.Split(wrapTranscript(rendered, contentWidth), "\n") |
| 321 | |
| 322 | copyLines, ok := m.copyTranscriptLines() |
| 323 | if !ok { |
| 324 | t.Fatal("copy rendition diverged from the displayed transcript") |
| 325 | } |
| 326 | firstLine, lastLine := -1, -1 |
| 327 | firstCol, lastCol := 0, 0 |
| 328 | for i, line := range copyLines { |
| 329 | if len(line.math) == 0 { |
| 330 | continue |
| 331 | } |
| 332 | if firstLine < 0 { |
| 333 | firstLine = i |
| 334 | firstCol = line.math[0].start |
| 335 | } |
| 336 | lastLine = i |
| 337 | lastCol = line.math[len(line.math)-1].end |
| 338 | } |
| 339 | if firstLine < 0 || lastLine <= firstLine { |
| 340 | t.Fatalf("expected formula to wrap across lines:\n%s", ansi.Strip(rendered)) |
| 341 | } |
| 342 | |
| 343 | m.sel = selection{ |
| 344 | active: true, |
| 345 | anchor: selPos{line: firstLine, col: firstCol}, |
| 346 | head: selPos{line: lastLine, col: lastCol}, |
| 347 | } |
| 348 | if got, want := m.selectedText(), `$`+latex+`$`; got != want { |
| 349 | t.Fatalf("wrapped formula selection = %q, want %q", got, want) |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | func TestCopyToClipboard(t *testing.T) { |
| 354 | t.Setenv("SSH_CONNECTION", "") |
| 355 | t.Setenv("SSH_CLIENT", "") |
| 356 | t.Setenv("SSH_TTY", "") |
| 357 | previous := writeNativeClipboardText |
| 358 | t.Cleanup(func() { writeNativeClipboardText = previous }) |
| 359 | |
| 360 | var written string |
| 361 | writeNativeClipboardText = func(text string) error { |
| 362 | written = text |
| 363 | return nil |
| 364 | } |
| 365 | message := copyToClipboard("hello")() |
| 366 | got, ok := message.(clipboardCopyMsg) |
| 367 | if !ok { |
| 368 | t.Fatalf("copyToClipboard returned %T, want clipboardCopyMsg", message) |
| 369 | } |
| 370 | if written != "hello" || got.text != "hello" || got.err != nil || got.osc52 { |
| 371 | t.Fatalf("native clipboard result = %+v, written %q", got, written) |
| 372 | } |
| 373 | |
| 374 | wantErr := errors.New("clipboard unavailable") |
| 375 | writeNativeClipboardText = func(string) error { return wantErr } |
| 376 | got = copyToClipboard("fallback")().(clipboardCopyMsg) |
| 377 | if !errors.Is(got.err, wantErr) || got.osc52 { |
| 378 | t.Fatalf("failed native clipboard result = %+v", got) |
| 379 | } |
| 380 | |
| 381 | t.Setenv("SSH_CONNECTION", "host 22 client 1234") |
| 382 | writeNativeClipboardText = func(string) error { |
| 383 | t.Fatal("SSH copy must not write the remote host's native clipboard") |
| 384 | return nil |
| 385 | } |
| 386 | got = copyToClipboard("remote")().(clipboardCopyMsg) |
| 387 | if !got.osc52 || got.text != "remote" { |
| 388 | t.Fatalf("SSH clipboard result = %+v, want OSC 52", got) |
| 389 | } |
| 390 | } |
| 391 |