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
7 changes: 5 additions & 2 deletions internal/tui/assistant_markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"unicode/utf8"

"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)

const (
Expand Down Expand Up @@ -237,8 +238,10 @@ func renderAssistantMarkdownPlainText(text string, proseMeasure int, tableMeasur
}

func stripMarkdownRenderControls(text string) string {
text = strings.ReplaceAll(text, markdownBoldStart, "")
return strings.ReplaceAll(text, markdownBoldEnd, "")
// renderAssistantMarkdownText embeds markdownBoldStart/markdownBoldEnd
// markers for prose text, and highlightCodeAuto embeds ANSI color sequences
// for fenced code blocks. Strip everything so clipboard text is pure plain text.
return ansi.Strip(text)
}

func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
}

func (m model) composerMouseSelectionBlocked() bool {
return m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
return m.transcriptDetailed || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil ||
m.mcpManager != nil || m.picker != nil || m.suggestionsActive()
}

Expand Down
51 changes: 50 additions & 1 deletion internal/tui/file_view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ func TestFileViewSwapsTranscriptBody(t *testing.T) {
m := filesPanelTestModel()
m = m.openFileView("internal/tui/sidebar.go")

items := m.transcriptBodyItems(m.chatColumnWidth(), "")
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
if len(items) != 1 {
t.Fatalf("file view should swap the body to a single block item, got %d items", len(items))
}
Expand Down Expand Up @@ -264,6 +264,55 @@ func TestFileViewFullBodyTruncatesLongFile(t *testing.T) {
}
}

// TestDetailedTranscriptClosesFileView: entering detailed transcript mode
// closes an active file drill-in so the full transcript body replaces the file
// content.
func TestDetailedTranscriptClosesFileView(t *testing.T) {
m := filesPanelTestModel()
m.altScreen = true
m = m.openFileView("web/app.js")

if !m.fileView.active {
t.Fatal("sanity check: openFileView should activate the file view")
}

updated, _ := m.Update(testKeyCtrl('o'))
m = updated.(model)

if m.fileView.active {
t.Fatal("detailed transcript should close the file drill-in")
}
if !m.transcriptDetailed {
t.Fatal("Ctrl+O should enter detailed mode")
}

items := m.transcriptBodyItems(m.chatColumnWidth(), "", true)
if len(items) <= 1 {
t.Fatalf("detailed transcript should show multiple body items, got %d", len(items))
}
}

// TestDetailedTranscriptStaysClosedOnSecondToggle: toggling out and back into
// detailed mode does not re-open the closed file view.
func TestDetailedTranscriptStaysClosedOnSecondToggle(t *testing.T) {
m := filesPanelTestModel()
m.altScreen = true
m = m.openFileView("web/app.js")

updated, _ := m.Update(testKeyCtrl('o'))
m = updated.(model)

updated, _ = m.Update(testKeyCtrl('o'))
m = updated.(model)

if m.fileView.active {
t.Fatal("exiting detailed mode must not re-open the file drill-in")
}
if m.transcriptDetailed {
t.Fatal("second Ctrl+O should exit detailed mode")
}
}

// TestFileViewKeysDeferToBlockingModal: with a permission prompt up, Esc and
// the d/f shortcuts belong to the prompt — the drill-in must not swallow them
// (Esc exiting the view instead of reaching the prompt's deny handling).
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/files_panel.go
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ func (m model) scrollOffsetForTranscriptRow(rowIndex int) (int, bool) {
return 0, false
}
width := m.chatColumnWidth()
items := m.transcriptBodyItems(width, "")
items := m.transcriptBodyItems(width, "", false)
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
startY := -1
for _, span := range metrics.spans {
Expand Down
32 changes: 16 additions & 16 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -1307,7 +1307,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// home screen or a narrow terminal. Hiding reflows the chat to full
// width, so mirror the width-change bookkeeping (re-wrap the streaming
// fade, resize the composer) the WindowSizeMsg path does.
if m.noBlockingModal() && m.sidebarToggleAllowed() {
if !m.transcriptDetailed && m.noBlockingModal() && m.sidebarToggleAllowed() {
// Just show/hide — no transcript notice. The reflow IS the feedback,
// and emitting a line every toggle piled up noise in the chat.
m.sidebarHidden = !m.sidebarHidden
Expand Down Expand Up @@ -1374,24 +1374,15 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
case keyIs(msg, tea.KeyPgUp):
if m.transcriptDetailed {
return m, nil
}
// A stationary mouse over a bodyY-keyed transcript hover target would
// otherwise stay lit at the scrolled-away bodyY until the next real
// motion event (see clearHover) — same reasoning as the wheel-scroll
// cases in mouse.go.
m = m.clearHover()
return m.scrollChat(m.chatPageScrollLines()), nil
case keyIs(msg, tea.KeyPgDown):
if m.transcriptDetailed {
return m, nil
}
m = m.clearHover()
return m.scrollChat(-m.chatPageScrollLines()), nil
case keyIs(msg, tea.KeyDown):
if m.transcriptDetailed {
return m, nil
m = m.clearHover()
return m.scrollChat(-1), nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(1), nil
Expand Down Expand Up @@ -1433,7 +1424,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
if m.transcriptDetailed {
return m, nil
m = m.clearHover()
return m.scrollChat(1), nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(-1), nil
Expand Down Expand Up @@ -2247,7 +2239,7 @@ func (m model) transcriptView() string {
if m.transcriptEmpty() && !m.pending && viewportOverlay != "" {
emptyOverlay = viewportOverlay
}
bodyItems := m.transcriptBodyItems(width, emptyOverlay)
bodyItems := m.transcriptBodyItems(width, emptyOverlay, false)

footer := m.footerView(width)

Expand Down Expand Up @@ -2287,7 +2279,7 @@ func (m model) twoColumnTranscriptView() string {
width := chatW

suggestionOverlay := m.suggestionOverlay(width)
bodyItems := m.transcriptBodyItems(width, "")
bodyItems := m.transcriptBodyItems(width, "", false)
footer := m.footerView(width)
overlayForViewport := suggestionOverlay
if m.transcriptEmpty() && !m.pending {
Expand Down Expand Up @@ -2717,7 +2709,15 @@ func (m model) chatTranscriptViewport() (transcriptViewport, bool) {
return transcriptViewport{}, false
}
width := m.chatColumnWidth()
items := m.transcriptBodyItems(width, "")
if m.transcriptDetailed {
items := m.transcriptBodyItems(width, "", true)
body := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
header := detailedTranscriptHeader(width) + "\n" + zeroTheme.line.Render(strings.Repeat("-", width))
footer := m.detailedTranscriptFooter(width)
frame := m.scrollableTranscriptFrame(header, footer)
return transcriptViewportForLayout(body, frame, m.chatScrollOffset), true
}
items := m.transcriptBodyItems(width, "", false)
body := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
return transcriptViewportForLayout(body, frame, m.chatScrollOffset), true
Expand Down
14 changes: 7 additions & 7 deletions internal/tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2142,7 +2142,7 @@ func TestTranscriptSelectionPaintsHighlightOnceNotTwice(t *testing.T) {
width := m.chatColumnWidth()

findRow := func(mm model) (transcriptBodyItem, bool) {
for _, it := range mm.transcriptBodyItems(width, "") {
for _, it := range mm.transcriptBodyItems(width, "", false) {
if it.kind == transcriptBodyItemRow && it.rowIndex >= 0 {
return it, true
}
Expand Down Expand Up @@ -2198,7 +2198,7 @@ func TestReasoningAfterToolCardGetsBlankSeparator(t *testing.T) {
transcriptRow{kind: rowReasoning, text: "Considering the next step in detail before acting"},
transcriptRow{kind: rowToolResult, id: "t2", tool: "read_file", status: tools.StatusOK, detail: "x\ny"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
reasoningIdx := -1
for i := range items {
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
Expand All @@ -2223,7 +2223,7 @@ func TestAssistantNarrationBeforeToolCardGetsBlankSeparator(t *testing.T) {
transcriptRow{kind: rowAssistant, text: "I'll inspect the existing file, then run it."},
transcriptRow{kind: rowToolResult, id: "t1", tool: "read_file", status: tools.StatusOK, detail: "File: time_test.py\n\n 1 | print('x')"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
toolIdx := -1
for i := range items {
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
Expand All @@ -2247,7 +2247,7 @@ func TestUserPromptBeforeToolCardGetsBlankSeparator(t *testing.T) {
transcriptRow{kind: rowUser, text: "create the landing page"},
transcriptRow{kind: rowToolResult, id: "t1", tool: "write_file", status: tools.StatusOK, detail: "--- /dev/null\n+++ b/index.html\n@@ -0,0 +1,1 @@\n+<!DOCTYPE html>"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
toolIdx := -1
for i := range items {
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
Expand All @@ -2273,7 +2273,7 @@ func TestAssistantAfterToolCardGetsRuleSeparator(t *testing.T) {
transcriptRow{kind: rowToolResult, id: "t1", tool: "bash", status: tools.StatusOK, detail: "stdout:\nok\nexit_code: 0"},
transcriptRow{kind: rowAssistant, text: "Done.", final: true},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
finalIdx := -1
for i := range items {
if items[i].rowIndex >= 0 && items[i].rowIndex < len(m.transcript) &&
Expand Down Expand Up @@ -2307,7 +2307,7 @@ func TestStreamingAssistantAfterToolCardGetsRuleSeparator(t *testing.T) {
transcriptRow{kind: rowAssistant, text: "I'll run it first."},
transcriptRow{kind: rowToolResult, id: "t1", tool: "bash", status: tools.StatusOK, detail: "stdout:\nok\nexit_code: 0"},
)
items := m.transcriptBodyItems(m.chatColumnWidth(), "")
items := m.transcriptBodyItems(m.chatColumnWidth(), "", false)
pendingIdx := -1
for i := range items {
if items[i].kind == transcriptBodyItemPendingInterim {
Expand Down Expand Up @@ -2357,7 +2357,7 @@ func TestTranscriptBodyRowsUseFullWidthAndAlignSelection(t *testing.T) {
t.Fatalf("expected no body gutter at width %d, got %d", width, gutter)
}

items := m.transcriptBodyItems(width, "")
items := m.transcriptBodyItems(width, "", false)
var row *transcriptBodyItem
for i := range items {
if items[i].kind == transcriptBodyItemRow && items[i].rowIndex >= 0 {
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/mouse.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ func (m model) syncMouseCapture() (model, tea.Cmd) {
}

func (m model) mouseOverComposer(msg tea.MouseMsg) bool {
if !m.altScreen || m.height <= 0 {
if !m.altScreen || m.height <= 0 || m.transcriptDetailed {
return false
}
width := m.chatColumnWidth()
Expand Down
24 changes: 24 additions & 0 deletions internal/tui/mouse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,30 @@ func TestMouseCaptureOnlyDuringInteractiveSetupStages(t *testing.T) {
}
}

// TestComposerMouseSelectionBlockedInDetailedMode: composer mouse hit-testing
// is disabled while the detailed transcript view is active, so wheel and click
// events in the composer area reach the transcript body instead.
func TestComposerMouseSelectionBlockedInDetailedMode(t *testing.T) {
m := mouseTestModel()
m.input.SetValue("some text")

updated, _ := m.Update(testKeyCtrl('o'))
m = updated.(model)

if !m.transcriptDetailed {
t.Fatal("sanity check: Ctrl+O should enter detailed mode")
}
if !m.composerMouseSelectionBlocked() {
t.Fatal("composerMouseSelectionBlocked should be true in detailed mode")
}

width := m.chatColumnWidth()
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if m.mouseOverComposer(testMouseWheel(tea.MouseWheelUp, 0, frame.composerRect.y)) {
t.Fatal("mouseOverComposer should return false in detailed mode")
}
}

func firstTranscriptTextMouseY(t *testing.T, m model) int {
t.Helper()
_, y := firstTranscriptTextMousePoint(t, m)
Expand Down
44 changes: 44 additions & 0 deletions internal/tui/rendering_lime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,50 @@ func TestSelectableAssistantRowKeepsMarkdownSemanticsPlain(t *testing.T) {
}
}

func TestStripMarkdownRenderControlsStripsAllANSI(t *testing.T) {
// Bold markers that renderAssistantMarkdownText embeds for prose formatting.
withBold := "\x1b[1mhello\x1b[22m world"
if got := stripMarkdownRenderControls(withBold); got != "hello world" {
t.Fatalf("stripMarkdownRenderControls(%q) = %q, want %q", withBold, got, "hello world")
}
// Color sequences from highlightCodeAuto for fenced code blocks.
withColor := "\x1b[38;2;236;236;238mdef\x1b[0m hello():"
if got := stripMarkdownRenderControls(withColor); got != "def hello():" {
t.Fatalf("stripMarkdownRenderControls(%q) = %q, want %q", withColor, got, "def hello():")
}
// Sequences that combine both (bold + underline + color).
withBoldColor := "\x1b[1;4;38;2;236;236;238mW\x1b[m"
if got := stripMarkdownRenderControls(withBoldColor); got != "W" {
t.Fatalf("stripMarkdownRenderControls(%q) = %q, want %q", withBoldColor, got, "W")
}
}

func TestSelectedTranscriptTextStripsANSIFromHighlightedCode(t *testing.T) {
m := limeTestModel()
m.width, m.height = 120, 40
m.altScreen = true
m.headerPrinted = true
code := `package main

import "fmt"

func main() {
fmt.Println("hello")
}`
row := transcriptRow{kind: rowAssistant, text: "First, here's the code:\n\n```go\n" + code + "\n```\n\nThat's it.", final: true}
m.transcript = append(m.transcript, row)
m.flushed = len(m.transcript)

// The highlighted code block contains ANSI color sequences from chroma;
// verify none survive in the selectable text used for clipboard copy.
_, selectable := m.renderSelectableAssistantRow(0, row, 72, 0)
for _, line := range selectable {
if ansiPattern.MatchString(line.text) {
t.Fatalf("selectable text leaked ANSI escapes: %q", line.text)
}
}
}

func TestFinalAnswerRendersPlainTextAndCompletionLine(t *testing.T) {
m := limeTestModel()
row := transcriptRow{
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func (m model) sidebarToggleAllowed() bool {
// would have any visible effect. The subchat drill-in keeps its own single-column
// view, so the sidebar is suppressed there.
func (m model) sidebarAvailable() bool {
if !m.altScreen || m.height <= 0 || m.subchat.active {
if !m.altScreen || m.height <= 0 || m.subchat.active || m.transcriptDetailed {
return false
}
if sidebarWidth(m.width) <= 0 {
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/specialist_summary_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func TestTranscriptBodyItemsIncludesSummaryBeforeCards(t *testing.T) {
specialistInfo: &specialistInfo{name: "explorer", description: "map code", childSessionID: "s2", status: specialistCompleted, startedAt: now, completedAt: now, tokenCount: 2000},
})

items := m.transcriptBodyItems(80, "")
items := m.transcriptBodyItems(80, "", false)
body := bodyItemsString(m, items, 80)
if !strings.Contains(body, "specialists") {
t.Errorf("transcript body should contain summary line with 'specialists', got:\n%s", body)
Expand Down
8 changes: 4 additions & 4 deletions internal/tui/transcript_body_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ func TestTranscriptBodyItemsRepresentEmptyState(t *testing.T) {
m := mouseTestModel()
width := m.chatColumnWidth()

items := m.transcriptBodyItems(width, "")
items := m.transcriptBodyItems(width, "", false)
layout := layoutTranscriptBodyItems(items)

if len(layout.spans) != 1 || layout.spans[0].kind != transcriptBodyItemEmpty {
Expand All @@ -25,7 +25,7 @@ func TestTranscriptBodyItemsShiftSelectableLinesByItemStart(t *testing.T) {
m.transcript = appendRow(m.transcript, rowUser, "hello")
width := m.chatColumnWidth()

layout := layoutTranscriptBodyItems(m.transcriptBodyItems(width, ""))
layout := layoutTranscriptBodyItems(m.transcriptBodyItems(width, "", false))

if len(layout.spans) != 1 || layout.spans[0].kind != transcriptBodyItemRow {
t.Fatalf("spans = %#v, want one transcript row item", layout.spans)
Expand All @@ -51,7 +51,7 @@ func TestTranscriptBodyItemsKeepPendingInterimSelectableLocal(t *testing.T) {
m.streamingReasoning = "private thought"
width := m.chatColumnWidth()

layout := layoutTranscriptBodyItems(m.transcriptBodyItems(width, ""))
layout := layoutTranscriptBodyItems(m.transcriptBodyItems(width, "", false))

if len(layout.spans) != 2 {
t.Fatalf("spans = %#v, want separator plus pending interim", layout.spans)
Expand Down Expand Up @@ -181,7 +181,7 @@ func TestScrollableTranscriptItemsViewMatchesFullLayout(t *testing.T) {
width := m.chatColumnWidth()
header := m.pinnedTitleBar(width)
footer := m.footerView(width)
items := m.transcriptBodyItems(width, "")
items := m.transcriptBodyItems(width, "", false)
full := layoutTranscriptBodyItems(items)

got := m.scrollableTranscriptItemsView(header, items, footer, width, "")
Expand Down
Loading
Loading