Skip to content

Commit 998a35e

Browse files
authored
Merge pull request #19 from 1024XEngineer/fix/tui-markdown-render-pollution
fix(tui): close transcript selection regressions and add coverage tests
2 parents 5a1eb3e + 146d85e commit 998a35e

4 files changed

Lines changed: 245 additions & 16 deletions

File tree

internal/tui/core/app/copy_code.go

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -269,11 +269,8 @@ func (a App) textSelectionRange(lines []string) (startLine int, startCol int, en
269269
if !a.textSelection.active || len(lines) == 0 {
270270
return 0, 0, 0, 0, false
271271
}
272-
sLine, sCol, sOk := a.normalizeSelectionPosition(lines, a.textSelection.startLine, a.textSelection.startCol)
273-
eLine, eCol, eOk := a.normalizeSelectionPosition(lines, a.textSelection.endLine, a.textSelection.endCol)
274-
if !sOk || !eOk {
275-
return 0, 0, 0, 0, false
276-
}
272+
sLine, sCol, _ := a.normalizeSelectionPosition(lines, a.textSelection.startLine, a.textSelection.startCol)
273+
eLine, eCol, _ := a.normalizeSelectionPosition(lines, a.textSelection.endLine, a.textSelection.endCol)
277274
if sLine > eLine || (sLine == eLine && sCol > eCol) {
278275
sLine, eLine = eLine, sLine
279276
sCol, eCol = eCol, sCol
@@ -359,15 +356,6 @@ func (a *App) copySelectionToClipboard() {
359356
if i == endLine {
360357
to = endCol
361358
}
362-
if from < 0 {
363-
from = 0
364-
}
365-
if to > lineWidth {
366-
to = lineWidth
367-
}
368-
if to < from {
369-
to = from
370-
}
371359
selectedLines = append(selectedLines, ansi.Cut(plain, from, to))
372360
}
373361

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package tui
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
8+
tea "github.com/charmbracelet/bubbletea"
9+
providertypes "neo-code/internal/provider/types"
10+
)
11+
12+
func TestRebuildTranscriptDoesNotCollapseAssistantAcrossToolBoundary(t *testing.T) {
13+
app, _ := newTestApp(t)
14+
app.width = 120
15+
app.height = 32
16+
app.applyComponentLayout(true)
17+
app.activeMessages = []providertypes.Message{
18+
{Role: roleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("before tool")}},
19+
{Role: roleTool, Parts: []providertypes.ContentPart{providertypes.NewTextPart("tool output")}},
20+
{Role: roleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("after tool")}},
21+
}
22+
23+
app.rebuildTranscript()
24+
plain := copyCodeANSIPattern.ReplaceAllString(app.transcriptContent, "")
25+
if count := strings.Count(plain, messageTagAgent); count != 2 {
26+
t.Fatalf("expected two agent tags across tool boundary, got %d in %q", count, plain)
27+
}
28+
}
29+
30+
func TestHandleTranscriptMouseDragMotionWithButtonNone(t *testing.T) {
31+
app, _ := newTestApp(t)
32+
app.width = 100
33+
app.height = 24
34+
app.applyComponentLayout(true)
35+
app.setTranscriptContent(strings.Repeat("line\n", 40))
36+
37+
x, y, _, _ := app.transcriptBounds()
38+
if !app.handleTranscriptMouse(tea.MouseMsg{
39+
X: x + 2,
40+
Y: y + 1,
41+
Button: tea.MouseButtonLeft,
42+
Action: tea.MouseActionPress,
43+
}) {
44+
t.Fatalf("expected press to begin selection")
45+
}
46+
47+
if !app.handleTranscriptMouse(tea.MouseMsg{
48+
X: x + 6,
49+
Y: y + 2,
50+
Button: tea.MouseButtonNone,
51+
Action: tea.MouseActionMotion,
52+
Type: tea.MouseMotion,
53+
}) {
54+
t.Fatalf("expected motion with button none while dragging to be handled")
55+
}
56+
if app.textSelection.endLine != 2 || app.textSelection.endCol <= app.textSelection.startCol {
57+
t.Fatalf("expected selection to update on motion with button none, got line=%d col=%d", app.textSelection.endLine, app.textSelection.endCol)
58+
}
59+
}
60+
61+
func TestHighlightTranscriptContentKeepsStyleWhenZeroWidthOnLine(t *testing.T) {
62+
app, _ := newTestApp(t)
63+
app.width = 100
64+
app.height = 24
65+
app.applyComponentLayout(true)
66+
app.textSelection.active = true
67+
app.textSelection.startLine = 0
68+
app.textSelection.startCol = 1
69+
app.textSelection.endLine = 1
70+
app.textSelection.endCol = 0
71+
72+
content := "\x1b[31mabc\x1b[0m\n\x1b[32mxyz\x1b[0m"
73+
highlighted := app.highlightTranscriptContent(content)
74+
lines := strings.Split(highlighted, "\n")
75+
if len(lines) != 2 {
76+
t.Fatalf("expected two lines, got %d", len(lines))
77+
}
78+
if !strings.Contains(lines[1], "\x1b[32m") {
79+
t.Fatalf("expected zero-width selected line to keep existing ANSI style, got %q", lines[1])
80+
}
81+
}
82+
83+
func TestCopySelectionToClipboardFailureKeepsSelection(t *testing.T) {
84+
app, _ := newTestApp(t)
85+
app.width = 100
86+
app.height = 24
87+
app.applyComponentLayout(true)
88+
app.setTranscriptContent("hello world")
89+
app.textSelection.active = true
90+
app.textSelection.startLine = 0
91+
app.textSelection.startCol = 0
92+
app.textSelection.endLine = 0
93+
app.textSelection.endCol = 5
94+
95+
originalClipboard := clipboardWriteAll
96+
clipboardWriteAll = func(string) error {
97+
return fmt.Errorf("clipboard failed")
98+
}
99+
defer func() { clipboardWriteAll = originalClipboard }()
100+
101+
app.copySelectionToClipboard()
102+
if app.state.StatusText != "Failed to copy selection" {
103+
t.Fatalf("expected status on copy error, got %q", app.state.StatusText)
104+
}
105+
if !app.textSelection.active {
106+
t.Fatalf("expected selection to remain active on copy failure")
107+
}
108+
}
109+
110+
func TestHandleTranscriptMouseRightClickWithoutSelectionNoop(t *testing.T) {
111+
app, _ := newTestApp(t)
112+
app.width = 100
113+
app.height = 24
114+
app.applyComponentLayout(true)
115+
app.setTranscriptContent("line")
116+
x, y, _, _ := app.transcriptBounds()
117+
if app.handleTranscriptMouse(tea.MouseMsg{
118+
X: x + 1,
119+
Y: y + 1,
120+
Button: tea.MouseButtonRight,
121+
Action: tea.MouseActionPress,
122+
}) {
123+
t.Fatalf("expected right click without selection to be ignored")
124+
}
125+
}
126+
127+
func TestSelectionHelpersGuardAndClampBranches(t *testing.T) {
128+
app, _ := newTestApp(t)
129+
if _, _, _, _, ok := app.textSelectionRange([]string{"x"}); ok {
130+
t.Fatalf("expected inactive selection to return false")
131+
}
132+
if _, _, ok := app.normalizeSelectionPosition(nil, 0, 0); ok {
133+
t.Fatalf("expected normalizeSelectionPosition to reject empty lines")
134+
}
135+
136+
lines := []string{"abc", "de"}
137+
line, col, ok := app.normalizeSelectionPosition(lines, -3, 99)
138+
if !ok || line != 0 || col != 3 {
139+
t.Fatalf("expected clamp to first line end, got line=%d col=%d ok=%v", line, col, ok)
140+
}
141+
line, col, ok = app.normalizeSelectionPosition(lines, 9, -4)
142+
if !ok || line != 1 || col != 0 {
143+
t.Fatalf("expected clamp to last line start, got line=%d col=%d ok=%v", line, col, ok)
144+
}
145+
146+
app.textSelection.active = true
147+
app.textSelection.startLine = 1
148+
app.textSelection.startCol = 2
149+
app.textSelection.endLine = 0
150+
app.textSelection.endCol = 1
151+
startLine, startCol, endLine, endCol, rangeOK := app.textSelectionRange(lines)
152+
if !rangeOK || startLine != 0 || startCol != 1 || endLine != 1 || endCol != 2 {
153+
t.Fatalf("expected reversed range to normalize ordering, got %d:%d -> %d:%d ok=%v", startLine, startCol, endLine, endCol, rangeOK)
154+
}
155+
156+
app.textSelection.endLine = app.textSelection.startLine
157+
app.textSelection.endCol = app.textSelection.startCol
158+
if _, _, _, _, equalOK := app.textSelectionRange(lines); equalOK {
159+
t.Fatalf("expected empty range to be treated as no selection")
160+
}
161+
}
162+
163+
func TestSplitMarkdownSegmentsFallbackWhenFenceHasNoCode(t *testing.T) {
164+
segments := splitMarkdownSegments("```go\n```")
165+
if len(segments) != 1 {
166+
t.Fatalf("expected fallback text segment count 1, got %d", len(segments))
167+
}
168+
if segments[0].Kind != markdownSegmentText {
169+
t.Fatalf("expected fallback text segment, got kind=%v", segments[0].Kind)
170+
}
171+
172+
indented := splitIndentedCodeSegments(" \n")
173+
if len(indented) != 1 || indented[0].Kind != markdownSegmentText {
174+
t.Fatalf("expected blank indented content to stay text, got %+v", indented)
175+
}
176+
}
177+
178+
func TestSelectionPositionAndDragGuardBranches(t *testing.T) {
179+
app, _ := newTestApp(t)
180+
app.width = 100
181+
app.height = 24
182+
app.applyComponentLayout(true)
183+
app.setTranscriptContent("alpha\nbeta")
184+
185+
if _, _, ok := app.selectionPositionAtMouse(tea.MouseMsg{X: -1, Y: -1}); ok {
186+
t.Fatalf("expected outside transcript mouse position to be rejected")
187+
}
188+
if app.beginTextSelection(tea.MouseMsg{X: -1, Y: -1}) {
189+
t.Fatalf("expected beginTextSelection outside transcript to fail")
190+
}
191+
if app.updateTextSelection(tea.MouseMsg{X: 0, Y: 0}) {
192+
t.Fatalf("expected updateTextSelection to fail when not dragging")
193+
}
194+
if app.finishTextSelection() {
195+
t.Fatalf("expected finishTextSelection to fail when not dragging")
196+
}
197+
198+
x, y, _, _ := app.transcriptBounds()
199+
if !app.beginTextSelection(tea.MouseMsg{X: x + 1, Y: y + 1}) {
200+
t.Fatalf("expected beginTextSelection to succeed in transcript")
201+
}
202+
if app.updateTextSelection(tea.MouseMsg{X: x - 2, Y: y - 1}) {
203+
t.Fatalf("expected updateTextSelection to fail when mouse moved outside transcript")
204+
}
205+
206+
app.textSelection.endLine = app.textSelection.startLine
207+
app.textSelection.endCol = app.textSelection.startCol
208+
if !app.finishTextSelection() {
209+
t.Fatalf("expected finishTextSelection to handle empty selection")
210+
}
211+
if app.textSelection.active {
212+
t.Fatalf("expected empty finished selection to be cleared")
213+
}
214+
}
215+
216+
func TestCopySelectionToClipboardNoSelectionNoop(t *testing.T) {
217+
app, _ := newTestApp(t)
218+
app.setTranscriptContent("hello")
219+
app.state.StatusText = "unchanged"
220+
app.copySelectionToClipboard()
221+
if app.state.StatusText != "unchanged" {
222+
t.Fatalf("expected no-selection copy to be noop, got status %q", app.state.StatusText)
223+
}
224+
}

internal/tui/core/app/update.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1848,7 +1848,7 @@ func (a *App) handleTranscriptMouse(msg tea.MouseMsg) bool {
18481848
switch {
18491849
case msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress:
18501850
return a.beginTextSelection(msg)
1851-
case msg.Button == tea.MouseButtonLeft && (msg.Action == tea.MouseActionMotion || msg.Type == tea.MouseMotion):
1851+
case (msg.Action == tea.MouseActionMotion || msg.Type == tea.MouseMotion) && a.textSelection.dragging:
18521852
return a.updateTextSelection(msg)
18531853
case msg.Action == tea.MouseActionRelease || msg.Type == tea.MouseRelease:
18541854
return a.finishTextSelection()
@@ -2314,6 +2314,8 @@ func (a *App) rebuildTranscript() {
23142314
previousRole := ""
23152315
for _, message := range a.activeMessages {
23162316
if message.Role == roleTool {
2317+
// tool 消息在 transcript 中不直接展示,但需要打断 assistant 连续分段。
2318+
previousRole = roleTool
23172319
continue
23182320
}
23192321
continuation := message.Role == roleAssistant && previousRole == roleAssistant
@@ -2375,7 +2377,6 @@ func (a *App) highlightTranscriptContent(content string) string {
23752377
selStart = max(0, min(selStart, lineWidth))
23762378
selEnd = max(selStart, min(selEnd, lineWidth))
23772379
if selEnd <= selStart {
2378-
lines[i] = plain
23792380
continue
23802381
}
23812382
prefix := ansi.Cut(plain, 0, selStart)

internal/tui/core/app/view_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,22 @@ func TestRenderWaterfallThinkingState(t *testing.T) {
125125
}
126126
}
127127

128+
func TestRenderWaterfallSelectionHint(t *testing.T) {
129+
app, _ := newTestApp(t)
130+
app.state.ActivePicker = pickerNone
131+
app.textSelection.active = true
132+
app.textSelection.startLine = 0
133+
app.textSelection.startCol = 0
134+
app.textSelection.endLine = 0
135+
app.textSelection.endCol = 1
136+
app.setTranscriptContent("hello")
137+
138+
view := app.renderWaterfall(80, 24)
139+
if !strings.Contains(view, "已选择内容,右键复制") {
140+
t.Fatalf("expected selection hint in waterfall view")
141+
}
142+
}
143+
128144
func TestApplyComponentLayoutKeepsTranscriptHeightInSyncWithWaterfall(t *testing.T) {
129145
app, _ := newTestApp(t)
130146
app.width = 100

0 commit comments

Comments
 (0)