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