| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "unicode/utf8" |
| 8 | |
| 9 | "github.com/charmbracelet/colorprofile" |
| 10 | "github.com/charmbracelet/x/ansi" |
| 11 | |
| 12 | "reasonix/internal/provider" |
| 13 | ) |
| 14 | |
| 15 | func transcriptSelectionModel(source transcriptSource) chatTUI { |
| 16 | m := newTestChatTUI() |
| 17 | contentWidth := transcriptContentWidth(m.width, m.nativeScrollback) |
| 18 | m.viewport.SetWidth(contentWidth) |
| 19 | rendered := m.renderTranscriptSource(source, m.width) |
| 20 | m.transcript = []string{rendered} |
| 21 | m.transcriptSources = []transcriptSource{source} |
| 22 | m.wrappedLines = wrapBlockLines(rendered, contentWidth) |
| 23 | return m |
| 24 | } |
| 25 | |
| 26 | func transcriptLineContaining(t *testing.T, lines []string, needle string) int { |
| 27 | t.Helper() |
| 28 | for i, line := range lines { |
| 29 | if strings.Contains(ansi.Strip(line), needle) { |
| 30 | return i |
| 31 | } |
| 32 | } |
| 33 | t.Fatalf("rendered transcript did not contain %q:\n%s", needle, ansi.Strip(strings.Join(lines, "\n"))) |
| 34 | return -1 |
| 35 | } |
| 36 | |
| 37 | func selectTranscriptLines(m *chatTUI, first, last int) string { |
| 38 | m.sel = selection{ |
| 39 | active: true, |
| 40 | anchor: selPos{line: first}, |
| 41 | head: selPos{line: last, col: ansi.StringWidth(m.wrappedLines[last])}, |
| 42 | } |
| 43 | return m.selectedText() |
| 44 | } |
| 45 | |
| 46 | func TestSelectedTextStripsRenderedMarkdownGutters(t *testing.T) { |
| 47 | defer restoreThemeForTest(activeColorProfile, activeCLITheme) |
| 48 | activeColorProfile = colorprofile.TrueColor |
| 49 | configureCLITheme("dark") |
| 50 | |
| 51 | m := transcriptSelectionModel(transcriptSource{ |
| 52 | kind: transcriptSourceMarkdown, |
| 53 | raw: "```go\nconst x = 1\nif x > 0 {\n```", |
| 54 | }) |
| 55 | first := transcriptLineContaining(t, m.wrappedLines, "│ const x = 1") |
| 56 | last := transcriptLineContaining(t, m.wrappedLines, "│ if x > 0 {") |
| 57 | if got, want := selectTranscriptLines(&m, first, last), "const x = 1\nif x > 0 {"; got != want { |
| 58 | t.Fatalf("fenced selection = %q, want %q", got, want) |
| 59 | } |
| 60 | plain := ansi.Strip(m.wrappedLines[first]) |
| 61 | contentCol := ansi.StringWidth(strings.Split(plain, "const x")[0]) |
| 62 | m.sel = selection{ |
| 63 | active: true, |
| 64 | anchor: selPos{line: first, col: contentCol}, |
| 65 | head: selPos{line: first, col: contentCol + ansi.StringWidth("const x")}, |
| 66 | } |
| 67 | if got, want := m.selectedText(), "const x"; got != want { |
| 68 | t.Fatalf("mid-line fenced selection = %q, want %q", got, want) |
| 69 | } |
| 70 | |
| 71 | m = transcriptSelectionModel(transcriptSource{kind: transcriptSourceMarkdown, raw: "> quoted"}) |
| 72 | line := transcriptLineContaining(t, m.wrappedLines, "▎ quoted") |
| 73 | if got, want := selectTranscriptLines(&m, line, line), "quoted"; got != want { |
| 74 | t.Fatalf("blockquote selection = %q, want %q", got, want) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | func TestSelectedTextPreservesLiteralGutterGlyphs(t *testing.T) { |
| 79 | defer restoreThemeForTest(activeColorProfile, activeCLITheme) |
| 80 | activeColorProfile = colorprofile.NoTTY |
| 81 | configureCLITheme("dark") |
| 82 | |
| 83 | for _, literal := range []string{"│ literal", "▎ literal", "⎿ literal", "| table | row |"} { |
| 84 | m := transcriptSelectionModel(transcriptSource{kind: transcriptSourceMarkdown, raw: literal}) |
| 85 | line := transcriptLineContaining(t, m.wrappedLines, literal) |
| 86 | plain := strings.TrimRight(ansi.Strip(m.wrappedLines[line]), " ") |
| 87 | if got := selectTranscriptLines(&m, line, line); got != plain { |
| 88 | t.Fatalf("full-line selection for %q = %q, want displayed %q", literal, got, plain) |
| 89 | } |
| 90 | start := ansi.StringWidth(strings.Split(plain, literal)[0]) |
| 91 | m.sel = selection{ |
| 92 | active: true, |
| 93 | anchor: selPos{line: line, col: start}, |
| 94 | head: selPos{line: line, col: start + ansi.StringWidth(literal)}, |
| 95 | } |
| 96 | if got := m.selectedText(); got != literal { |
| 97 | t.Fatalf("literal selection for %q = %q", literal, got) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | m := newTestChatTUI() |
| 102 | m.wrappedLines = []string{" │ fallback literal"} |
| 103 | if got, want := selectTranscriptLines(&m, 0, 0), " │ fallback literal"; got != want { |
| 104 | t.Fatalf("display fallback = %q, want %q", got, want) |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | func TestSelectedTextStripsWholeConnectorGutter(t *testing.T) { |
| 109 | defer restoreThemeForTest(activeColorProfile, activeCLITheme) |
| 110 | activeColorProfile = colorprofile.NoTTY |
| 111 | configureCLITheme("dark") |
| 112 | |
| 113 | m := newTestChatTUI() |
| 114 | contentWidth := transcriptContentWidth(m.width, m.nativeScrollback) |
| 115 | m.viewport.SetWidth(contentWidth) |
| 116 | m.beginToolRunning("shell-copy-test") |
| 117 | m.streamToolOutput("shell-copy-test", "first\nsecond\n│ literal output") |
| 118 | m.wrappedLines = wrapBlockLines(m.transcript[0], contentWidth) |
| 119 | if got, want := selectTranscriptLines(&m, 0, 2), "first\nsecond\n│ literal output"; got != want { |
| 120 | t.Fatalf("connector selection = %q, want %q", got, want) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func TestSelectedTextStripsReplayReasoningConnector(t *testing.T) { |
| 125 | defer restoreThemeForTest(activeColorProfile, activeCLITheme) |
| 126 | activeColorProfile = colorprofile.NoTTY |
| 127 | configureCLITheme("dark") |
| 128 | |
| 129 | m := transcriptSelectionModel(transcriptSource{ |
| 130 | kind: transcriptSourceReplayBundle, |
| 131 | history: []provider.Message{{ |
| 132 | Role: provider.RoleAssistant, ReasoningContent: "first\nsecond", |
| 133 | }}, |
| 134 | }) |
| 135 | first := transcriptLineContaining(t, m.wrappedLines, "⎿ first") |
| 136 | last := transcriptLineContaining(t, m.wrappedLines, "second") |
| 137 | if got, want := selectTranscriptLines(&m, first, last), "first\nsecond"; got != want { |
| 138 | t.Fatalf("replayed reasoning selection = %q, want %q", got, want) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func TestNativeClipboardPassesCJKUTF8Unchanged(t *testing.T) { |
| 143 | t.Setenv("SSH_CONNECTION", "") |
| 144 | t.Setenv("SSH_CLIENT", "") |
| 145 | t.Setenv("SSH_TTY", "") |
| 146 | previous := writeNativeClipboardText |
| 147 | defer func() { writeNativeClipboardText = previous }() |
| 148 | |
| 149 | want := "这是中文复制验证 │ ▎ ⎿" |
| 150 | var written []byte |
| 151 | writeNativeClipboardText = func(text string) error { |
| 152 | written = append([]byte(nil), []byte(text)...) |
| 153 | return nil |
| 154 | } |
| 155 | msg := copyToClipboard(want)().(clipboardCopyMsg) |
| 156 | if msg.err != nil || msg.osc52 { |
| 157 | t.Fatalf("native clipboard result = %+v", msg) |
| 158 | } |
| 159 | if !utf8.Valid(written) || !bytes.Equal(written, []byte(want)) { |
| 160 | t.Fatalf("native clipboard bytes = %q, want unchanged UTF-8 %q", written, []byte(want)) |
| 161 | } |
| 162 | } |
| 163 |