Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 2 additions & 14 deletions internal/tui/core/app/copy_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,8 @@ func (a App) textSelectionRange(lines []string) (startLine int, startCol int, en
if !a.textSelection.active || len(lines) == 0 {
return 0, 0, 0, 0, false
}
sLine, sCol, sOk := a.normalizeSelectionPosition(lines, a.textSelection.startLine, a.textSelection.startCol)
eLine, eCol, eOk := a.normalizeSelectionPosition(lines, a.textSelection.endLine, a.textSelection.endCol)
if !sOk || !eOk {
return 0, 0, 0, 0, false
}
sLine, sCol, _ := a.normalizeSelectionPosition(lines, a.textSelection.startLine, a.textSelection.startCol)
eLine, eCol, _ := a.normalizeSelectionPosition(lines, a.textSelection.endLine, a.textSelection.endCol)
if sLine > eLine || (sLine == eLine && sCol > eCol) {
sLine, eLine = eLine, sLine
sCol, eCol = eCol, sCol
Expand Down Expand Up @@ -359,15 +356,6 @@ func (a *App) copySelectionToClipboard() {
if i == endLine {
to = endCol
}
if from < 0 {
from = 0
}
if to > lineWidth {
to = lineWidth
}
if to < from {
to = from
}
selectedLines = append(selectedLines, ansi.Cut(plain, from, to))
}

Expand Down
224 changes: 224 additions & 0 deletions internal/tui/core/app/copy_code_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package tui

import (
"fmt"
"strings"
"testing"

tea "github.com/charmbracelet/bubbletea"
providertypes "neo-code/internal/provider/types"
)

func TestRebuildTranscriptDoesNotCollapseAssistantAcrossToolBoundary(t *testing.T) {
app, _ := newTestApp(t)
app.width = 120
app.height = 32
app.applyComponentLayout(true)
app.activeMessages = []providertypes.Message{
{Role: roleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("before tool")}},
{Role: roleTool, Parts: []providertypes.ContentPart{providertypes.NewTextPart("tool output")}},
{Role: roleAssistant, Parts: []providertypes.ContentPart{providertypes.NewTextPart("after tool")}},
}

app.rebuildTranscript()
plain := copyCodeANSIPattern.ReplaceAllString(app.transcriptContent, "")
if count := strings.Count(plain, messageTagAgent); count != 2 {
t.Fatalf("expected two agent tags across tool boundary, got %d in %q", count, plain)
}
}

func TestHandleTranscriptMouseDragMotionWithButtonNone(t *testing.T) {
app, _ := newTestApp(t)
app.width = 100
app.height = 24
app.applyComponentLayout(true)
app.setTranscriptContent(strings.Repeat("line\n", 40))

x, y, _, _ := app.transcriptBounds()
if !app.handleTranscriptMouse(tea.MouseMsg{
X: x + 2,
Y: y + 1,
Button: tea.MouseButtonLeft,
Action: tea.MouseActionPress,
}) {
t.Fatalf("expected press to begin selection")
}

if !app.handleTranscriptMouse(tea.MouseMsg{
X: x + 6,
Y: y + 2,
Button: tea.MouseButtonNone,
Action: tea.MouseActionMotion,
Type: tea.MouseMotion,
}) {
t.Fatalf("expected motion with button none while dragging to be handled")
}
if app.textSelection.endLine != 2 || app.textSelection.endCol <= app.textSelection.startCol {
t.Fatalf("expected selection to update on motion with button none, got line=%d col=%d", app.textSelection.endLine, app.textSelection.endCol)
}
}

func TestHighlightTranscriptContentKeepsStyleWhenZeroWidthOnLine(t *testing.T) {
app, _ := newTestApp(t)
app.width = 100
app.height = 24
app.applyComponentLayout(true)
app.textSelection.active = true
app.textSelection.startLine = 0
app.textSelection.startCol = 1
app.textSelection.endLine = 1
app.textSelection.endCol = 0

content := "\x1b[31mabc\x1b[0m\n\x1b[32mxyz\x1b[0m"
highlighted := app.highlightTranscriptContent(content)
lines := strings.Split(highlighted, "\n")
if len(lines) != 2 {
t.Fatalf("expected two lines, got %d", len(lines))
}
if !strings.Contains(lines[1], "\x1b[32m") {
t.Fatalf("expected zero-width selected line to keep existing ANSI style, got %q", lines[1])
}
}

func TestCopySelectionToClipboardFailureKeepsSelection(t *testing.T) {
app, _ := newTestApp(t)
app.width = 100
app.height = 24
app.applyComponentLayout(true)
app.setTranscriptContent("hello world")
app.textSelection.active = true
app.textSelection.startLine = 0
app.textSelection.startCol = 0
app.textSelection.endLine = 0
app.textSelection.endCol = 5

originalClipboard := clipboardWriteAll
clipboardWriteAll = func(string) error {
return fmt.Errorf("clipboard failed")
}
defer func() { clipboardWriteAll = originalClipboard }()

app.copySelectionToClipboard()
if app.state.StatusText != "Failed to copy selection" {
t.Fatalf("expected status on copy error, got %q", app.state.StatusText)
}
if !app.textSelection.active {
t.Fatalf("expected selection to remain active on copy failure")
}
}

func TestHandleTranscriptMouseRightClickWithoutSelectionNoop(t *testing.T) {
app, _ := newTestApp(t)
app.width = 100
app.height = 24
app.applyComponentLayout(true)
app.setTranscriptContent("line")
x, y, _, _ := app.transcriptBounds()
if app.handleTranscriptMouse(tea.MouseMsg{
X: x + 1,
Y: y + 1,
Button: tea.MouseButtonRight,
Action: tea.MouseActionPress,
}) {
t.Fatalf("expected right click without selection to be ignored")
}
}

func TestSelectionHelpersGuardAndClampBranches(t *testing.T) {
app, _ := newTestApp(t)
if _, _, _, _, ok := app.textSelectionRange([]string{"x"}); ok {
t.Fatalf("expected inactive selection to return false")
}
if _, _, ok := app.normalizeSelectionPosition(nil, 0, 0); ok {
t.Fatalf("expected normalizeSelectionPosition to reject empty lines")
}

lines := []string{"abc", "de"}
line, col, ok := app.normalizeSelectionPosition(lines, -3, 99)
if !ok || line != 0 || col != 3 {
t.Fatalf("expected clamp to first line end, got line=%d col=%d ok=%v", line, col, ok)
}
line, col, ok = app.normalizeSelectionPosition(lines, 9, -4)
if !ok || line != 1 || col != 0 {
t.Fatalf("expected clamp to last line start, got line=%d col=%d ok=%v", line, col, ok)
}

app.textSelection.active = true
app.textSelection.startLine = 1
app.textSelection.startCol = 2
app.textSelection.endLine = 0
app.textSelection.endCol = 1
startLine, startCol, endLine, endCol, rangeOK := app.textSelectionRange(lines)
if !rangeOK || startLine != 0 || startCol != 1 || endLine != 1 || endCol != 2 {
t.Fatalf("expected reversed range to normalize ordering, got %d:%d -> %d:%d ok=%v", startLine, startCol, endLine, endCol, rangeOK)
}

app.textSelection.endLine = app.textSelection.startLine
app.textSelection.endCol = app.textSelection.startCol
if _, _, _, _, equalOK := app.textSelectionRange(lines); equalOK {
t.Fatalf("expected empty range to be treated as no selection")
}
}

func TestSplitMarkdownSegmentsFallbackWhenFenceHasNoCode(t *testing.T) {
segments := splitMarkdownSegments("```go\n```")
if len(segments) != 1 {
t.Fatalf("expected fallback text segment count 1, got %d", len(segments))
}
if segments[0].Kind != markdownSegmentText {
t.Fatalf("expected fallback text segment, got kind=%v", segments[0].Kind)
}

indented := splitIndentedCodeSegments(" \n")
if len(indented) != 1 || indented[0].Kind != markdownSegmentText {
t.Fatalf("expected blank indented content to stay text, got %+v", indented)
}
}

func TestSelectionPositionAndDragGuardBranches(t *testing.T) {
app, _ := newTestApp(t)
app.width = 100
app.height = 24
app.applyComponentLayout(true)
app.setTranscriptContent("alpha\nbeta")

if _, _, ok := app.selectionPositionAtMouse(tea.MouseMsg{X: -1, Y: -1}); ok {
t.Fatalf("expected outside transcript mouse position to be rejected")
}
if app.beginTextSelection(tea.MouseMsg{X: -1, Y: -1}) {
t.Fatalf("expected beginTextSelection outside transcript to fail")
}
if app.updateTextSelection(tea.MouseMsg{X: 0, Y: 0}) {
t.Fatalf("expected updateTextSelection to fail when not dragging")
}
if app.finishTextSelection() {
t.Fatalf("expected finishTextSelection to fail when not dragging")
}

x, y, _, _ := app.transcriptBounds()
if !app.beginTextSelection(tea.MouseMsg{X: x + 1, Y: y + 1}) {
t.Fatalf("expected beginTextSelection to succeed in transcript")
}
if app.updateTextSelection(tea.MouseMsg{X: x - 2, Y: y - 1}) {
t.Fatalf("expected updateTextSelection to fail when mouse moved outside transcript")
}

app.textSelection.endLine = app.textSelection.startLine
app.textSelection.endCol = app.textSelection.startCol
if !app.finishTextSelection() {
t.Fatalf("expected finishTextSelection to handle empty selection")
}
if app.textSelection.active {
t.Fatalf("expected empty finished selection to be cleared")
}
}

func TestCopySelectionToClipboardNoSelectionNoop(t *testing.T) {
app, _ := newTestApp(t)
app.setTranscriptContent("hello")
app.state.StatusText = "unchanged"
app.copySelectionToClipboard()
if app.state.StatusText != "unchanged" {
t.Fatalf("expected no-selection copy to be noop, got status %q", app.state.StatusText)
}
}
5 changes: 3 additions & 2 deletions internal/tui/core/app/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -1848,7 +1848,7 @@ func (a *App) handleTranscriptMouse(msg tea.MouseMsg) bool {
switch {
case msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress:
return a.beginTextSelection(msg)
case msg.Button == tea.MouseButtonLeft && (msg.Action == tea.MouseActionMotion || msg.Type == tea.MouseMotion):
case (msg.Action == tea.MouseActionMotion || msg.Type == tea.MouseMotion) && a.textSelection.dragging:
return a.updateTextSelection(msg)
case msg.Action == tea.MouseActionRelease || msg.Type == tea.MouseRelease:
return a.finishTextSelection()
Expand Down Expand Up @@ -2314,6 +2314,8 @@ func (a *App) rebuildTranscript() {
previousRole := ""
for _, message := range a.activeMessages {
if message.Role == roleTool {
// tool 消息在 transcript 中不直接展示,但需要打断 assistant 连续分段。
previousRole = roleTool
continue
}
continuation := message.Role == roleAssistant && previousRole == roleAssistant
Expand Down Expand Up @@ -2375,7 +2377,6 @@ func (a *App) highlightTranscriptContent(content string) string {
selStart = max(0, min(selStart, lineWidth))
selEnd = max(selStart, min(selEnd, lineWidth))
if selEnd <= selStart {
lines[i] = plain
continue
}
prefix := ansi.Cut(plain, 0, selStart)
Expand Down
16 changes: 16 additions & 0 deletions internal/tui/core/app/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,22 @@ func TestRenderWaterfallThinkingState(t *testing.T) {
}
}

func TestRenderWaterfallSelectionHint(t *testing.T) {
app, _ := newTestApp(t)
app.state.ActivePicker = pickerNone
app.textSelection.active = true
app.textSelection.startLine = 0
app.textSelection.startCol = 0
app.textSelection.endLine = 0
app.textSelection.endCol = 1
app.setTranscriptContent("hello")

view := app.renderWaterfall(80, 24)
if !strings.Contains(view, "已选择内容,右键复制") {
t.Fatalf("expected selection hint in waterfall view")
}
}

func TestApplyComponentLayoutKeepsTranscriptHeightInSyncWithWaterfall(t *testing.T) {
app, _ := newTestApp(t)
app.width = 100
Expand Down
Loading