diff --git a/internal/tui/assistant_markdown.go b/internal/tui/assistant_markdown.go index bf43f1ec..dffb262d 100644 --- a/internal/tui/assistant_markdown.go +++ b/internal/tui/assistant_markdown.go @@ -8,6 +8,7 @@ import ( "unicode/utf8" "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" ) const ( @@ -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 { diff --git a/internal/tui/composer.go b/internal/tui/composer.go index 400509b2..95883c03 100644 --- a/internal/tui/composer.go +++ b/internal/tui/composer.go @@ -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() } diff --git a/internal/tui/file_view_test.go b/internal/tui/file_view_test.go index ebc62adb..3856ea6d 100644 --- a/internal/tui/file_view_test.go +++ b/internal/tui/file_view_test.go @@ -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)) } @@ -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). diff --git a/internal/tui/files_panel.go b/internal/tui/files_panel.go index 01723d0f..4844a30b 100644 --- a/internal/tui/files_panel.go +++ b/internal/tui/files_panel.go @@ -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 { diff --git a/internal/tui/model.go b/internal/tui/model.go index acfafc9c..cb1ef0aa 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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 @@ -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 @@ -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 @@ -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) @@ -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 { @@ -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 diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 85eb242e..53559f17 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -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 } @@ -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) && @@ -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) && @@ -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+"}, ) - 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) && @@ -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) && @@ -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 { @@ -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 { diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 2572ab67..55ba76e8 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -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() diff --git a/internal/tui/mouse_test.go b/internal/tui/mouse_test.go index 758f528a..aac79b8a 100644 --- a/internal/tui/mouse_test.go +++ b/internal/tui/mouse_test.go @@ -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) diff --git a/internal/tui/rendering_lime_test.go b/internal/tui/rendering_lime_test.go index 261f1ec4..0d5713e7 100644 --- a/internal/tui/rendering_lime_test.go +++ b/internal/tui/rendering_lime_test.go @@ -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{ diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index d993c31a..a4528145 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -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 { diff --git a/internal/tui/specialist_summary_render_test.go b/internal/tui/specialist_summary_render_test.go index 67de8705..826fae86 100644 --- a/internal/tui/specialist_summary_render_test.go +++ b/internal/tui/specialist_summary_render_test.go @@ -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) diff --git a/internal/tui/transcript_body_test.go b/internal/tui/transcript_body_test.go index 3e2cc080..523564aa 100644 --- a/internal/tui/transcript_body_test.go +++ b/internal/tui/transcript_body_test.go @@ -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 { @@ -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) @@ -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) @@ -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, "") diff --git a/internal/tui/transcript_selection.go b/internal/tui/transcript_selection.go index f858da4a..a41c67fc 100644 --- a/internal/tui/transcript_selection.go +++ b/internal/tui/transcript_selection.go @@ -107,7 +107,7 @@ type transcriptBodyLayout struct { } func (m model) transcriptBodyLayout(width int, emptyOverlay string) transcriptBodyLayout { - return layoutTranscriptBodyItems(m.transcriptBodyItems(width, emptyOverlay)) + return layoutTranscriptBodyItems(m.transcriptBodyItems(width, emptyOverlay, false)) } func (m model) transcriptBody(width int, emptyOverlay string) (string, []transcriptSelectableLine) { @@ -210,7 +210,7 @@ func (m model) renderHoverHighlight(rendered string, selectable []transcriptSele return strings.Join(lines, "\n") } -func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptBodyItem { +func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool) []transcriptBodyItem { // File drill-in: the chat column's body swaps to the viewed file's // diff/content. Swapping HERE (the single source every consumer reads) keeps // the viewport, scroll engine, renderer, and mouse hit-tests consistent. @@ -241,9 +241,19 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptB } else { rc := buildRowContext(m.transcript) shownAny := false - previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, m.flushed, rc) + // The detailed view shows the full transcript from index 0, not + // the managed region after m.flushed. + startIdx := m.flushed + if detailed { + startIdx = 0 + } + renderRowFn := transcriptRowDispatchFn(m.renderTranscriptRow) + if detailed { + renderRowFn = transcriptRowDispatchFn(m.renderTranscriptDetailedRow) + } + previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, startIdx, rc) specialistSummaryEmitted := false - for index := m.flushed; index < len(m.transcript); index++ { + for index := startIdx; index < len(m.transcript); index++ { row := m.transcript[index] // A welcome row carries no Lime visual (the empty state replaced it) // and a resolved tool call collapses into its result's card. @@ -344,16 +354,18 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptB } } rowIndex, transcriptRow := index, row - // Key the height cache on contentWidth (the wrap width drives line count); - // the gutter is a horizontal-only post-pad and never changes height. - heightCacheKey, heightCacheStable := m.transcriptRowBodyHeightCacheKey(transcriptRow, contentWidth, rc) + bodyCap := cardBodyMaxLines + if detailed { + bodyCap = 0 + } + heightCacheKey, heightCacheStable := m.transcriptRowBodyHeightCacheKeyOpts(transcriptRow, contentWidth, rc, bodyCap) items = append(items, transcriptBodyItem{ kind: transcriptBodyItemRow, rowIndex: rowIndex, heightCacheKey: heightCacheKey, heightCacheStable: heightCacheStable, render: func(startBodyY int) transcriptBodyRenderedItem { - rendered, selectable := m.renderTranscriptRow(rowIndex, transcriptRow, contentWidth, rc, startBodyY) + rendered, selectable := renderRowFn(rowIndex, transcriptRow, contentWidth, rc, startBodyY) return m.finalizeTranscriptBodyRow(rendered, selectable, gutter, startBodyY) }, }) @@ -635,8 +647,17 @@ func transcriptBlockBodyHeightCacheKey(kind transcriptBodyItemKind, block string return b.String() } -func (m model) transcriptRowBodyHeightCacheKey(row transcriptRow, width int, rc rowContext) (string, bool) { - opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd} +// rowRenderFn abstracts row rendering for normal vs detailed mode: +// m.renderRow for normal (bodyCap: cardBodyMaxLines) and m.renderRowDetailed +// for detailed (bodyCap: 0). +type rowRenderFn func(transcriptRow, int, rowContext) string + +// transcriptRowDispatchFn dispatches row-kind rendering for the transcript body +// pipeline, producing rendered text and selectable-line geometry. +type transcriptRowDispatchFn func(int, transcriptRow, int, rowContext, int) (string, []transcriptSelectableLine) + +func (m model) transcriptRowBodyHeightCacheKeyOpts(row transcriptRow, width int, rc rowContext, bodyCap int) (string, bool) { + opts := cardRenderOptions{bodyCap: bodyCap, cwd: m.cwd} rowKey, stable := m.renderRowCacheKey(row, width, rc, opts, false) if rowKey == "" { return "", false @@ -648,6 +669,17 @@ func (m model) transcriptRowBodyHeightCacheKey(row transcriptRow, width int, rc } func (m model) renderTranscriptRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) { + return m.renderTranscriptRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow) +} + +// renderTranscriptDetailedRow routes through renderTranscriptRowFn with +// renderRowDetailed (bodyCap: 0) so tool output appears uncapped. +func (m model) renderTranscriptDetailedRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) { + return m.renderTranscriptRowFn(rowIndex, row, width, rc, startBodyY, m.renderRowDetailed) +} + +// renderTranscriptRowFn dispatches row-kind rendering using the provided renderFn. +func (m model) renderTranscriptRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) { switch row.kind { case rowUser: return m.renderSelectableUserRow(rowIndex, row, width, startBodyY) @@ -656,21 +688,26 @@ func (m model) renderTranscriptRow(rowIndex int, row transcriptRow, width int, r case rowReasoning: return m.renderSelectableReasoningRow(rowIndex, row, width, startBodyY) case rowSystem, rowError, rowToolCall, rowPermission, rowAskUser: - return m.renderSelectableRenderedRow(rowIndex, row, width, rc, startBodyY) + return m.renderSelectableRenderedRowFn(rowIndex, row, width, rc, startBodyY, renderFn) case rowToolResult: - return m.renderSelectableToolResultRow(rowIndex, row, width, rc, startBodyY) + return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, renderFn) case rowSpecialist: - return m.renderSelectableSpecialistRow(rowIndex, row, width, rc, startBodyY) + return m.renderSelectableSpecialistRowFn(rowIndex, row, width, rc, startBodyY, renderFn) default: - return m.renderRow(row, width, rc), nil + rendered := renderFn(row, width, rc) + if rendered == "" { + return "", nil + } + selectable := selectableLinesFromRendered(rowIndex, rendered, startBodyY, 0) + return rendered, selectable } } // renderSelectableToolResultRow renders the tool result card and marks its head // (first line) as a clickable collapse/expand toggle. Body/footer text remains // selectable so copying a visible transcript range includes command output. -func (m model) renderSelectableToolResultRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) { - rendered := m.renderRow(row, width, rc) +func (m model) renderSelectableToolResultRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) { + rendered := renderFn(row, width, rc) if rendered == "" { return "", nil } @@ -694,8 +731,12 @@ func (m model) renderSelectableToolResultRow(rowIndex int, row transcriptRow, wi return rendered, selectable } -func (m model) renderSelectableRenderedRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) { - rendered := m.renderRow(row, width, rc) +func (m model) renderSelectableToolResultRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) { + return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow) +} + +func (m model) renderSelectableRenderedRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) { + rendered := renderFn(row, width, rc) if rendered == "" { return "", nil } @@ -828,11 +869,8 @@ func (m model) renderTranscriptSelectableStyledLine(line transcriptSelectableLin return prefix + zeroTheme.selection.Render(selected) + suffix } -// renderSelectableSpecialistRow renders a specialist card and marks every line -// as a clickable specialistCard selectable line carrying the childSessionID. -// A left-click or Enter on any card line drills into that specialist's subchat. -func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) { - rendered := m.renderRow(row, width, rc) +func (m model) renderSelectableSpecialistRowFn(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int, renderFn rowRenderFn) (string, []transcriptSelectableLine) { + rendered := renderFn(row, width, rc) if rendered == "" || row.specialistInfo == nil { return "", nil } @@ -846,9 +884,6 @@ func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, wi specialistCard: true, specialistID: row.specialistInfo.childSessionID, } - // Carry the line's plain text + start so a selection dragged THROUGH the - // card copies its content; the specialistCard flag still makes a direct - // click drill into the sub-session (handled on press, before selection). if meta, ok := selectableLineFromRenderedLine(rowIndex, startBodyY+i, lines[i], boxed); ok { sl.text = meta.text sl.textStart = meta.textStart @@ -858,6 +893,13 @@ func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, wi return rendered, selectable } +// renderSelectableSpecialistRow renders a specialist card and marks every line +// as a clickable specialistCard selectable line carrying the childSessionID. +// A left-click or Enter on any card line drills into that specialist's subchat. +func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) { + return m.renderSelectableSpecialistRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow) +} + func (m model) renderSelectableUserRow(rowIndex int, row transcriptRow, width int, startBodyY int) (string, []transcriptSelectableLine) { contentWidth := userPromptContentWidth(width) wrapped := wrapPlainText(row.text, maxInt(1, contentWidth)) @@ -1030,12 +1072,18 @@ func splitPlainAtDisplayWidth(text string, width int) (string, string) { // selection previously resolved against transcript rows that weren't even // visible while viewing a subagent/swarm child session. func (m model) transcriptHitTestSource() (header string, items []transcriptBodyItem, width int) { + if m.transcriptDetailed { + width = chatWidth(m.width) + header = detailedTranscriptHeader(width) + "\n" + zeroTheme.line.Render(strings.Repeat("-", width)) + items = m.transcriptBodyItems(width, "", true) + return + } if m.subchat.active { width = chatWidth(m.width) return renderSubchatNavBar(m.subchat.childSessionTitle, width), m.transcriptBodyItemsFromRows(m.subchat.childRows, width), width } width = m.chatColumnWidth() - return m.pinnedTitleBar(width), m.transcriptBodyItems(width, ""), width + return m.pinnedTitleBar(width), m.transcriptBodyItems(width, "", false), width } // transcriptHitTestBlocked reports whether mouse hit-testing must be skipped @@ -1049,7 +1097,11 @@ func (m model) transcriptHitTestBlocked() bool { // (nearest-line fallback for scroll-driven selection extension). func (m model) transcriptHitTestLayout() (frame transcriptFrameLayout, window transcriptViewportWindow, layout transcriptBodyLayout) { header, items, width := m.transcriptHitTestSource() - frame = m.scrollableTranscriptFrame(header, m.footerView(width)) + footer := m.footerView(width) + if m.transcriptDetailed { + footer = m.detailedTranscriptFooter(width) + } + frame = m.scrollableTranscriptFrame(header, footer) metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights) window = transcriptViewportForLayout(metrics, frame, m.chatScrollOffset).window() layout = layoutVisibleTranscriptBodyItems(items, metrics, window) diff --git a/internal/tui/transcript_view.go b/internal/tui/transcript_view.go index 139dca3f..53a9d788 100644 --- a/internal/tui/transcript_view.go +++ b/internal/tui/transcript_view.go @@ -1,10 +1,16 @@ package tui -import "strings" +import ( + "fmt" + "strings" +) func (m model) toggleDetailedTranscript() model { m.transcriptDetailed = !m.transcriptDetailed m.clearSuggestions() + if m.fileView.active { + m = m.exitFileView() + } if m.picker != nil && m.picker.kind == pickerTheme { // Dropping a theme picker mid-preview must undo the live palette, or the // previewed theme would stick while m.themeMode still holds the old one. @@ -14,55 +20,36 @@ func (m model) toggleDetailedTranscript() model { return m } +// detailedTranscriptView renders the full (uncapped) transcript viewport. +// Tool output that would be truncated in the live view appears in full here. func (m model) detailedTranscriptView() string { width := chatWidth(m.width) - rc := buildRowContext(m.transcript) - renderer := m - renderer.pending = false - renderer.activeRunID = 0 + items := m.transcriptBodyItems(width, "", true) - var builder strings.Builder - builder.WriteString(detailedTranscriptHeader(width)) - builder.WriteString("\n") - builder.WriteString(zeroTheme.line.Render(strings.Repeat("-", width))) - builder.WriteString("\n") + header := detailedTranscriptHeader(width) + "\n" + zeroTheme.line.Render(strings.Repeat("-", width)) + footer := m.detailedTranscriptFooter(width) - shownAny := false - var previousKind rowKind - for _, row := range m.transcript { - if rc.skip(row) { - continue - } - rendered := "" - if row.kind == rowWelcome { - rendered = fitStyledLine(zeroTheme.faint.Render(row.text), width) - } else { - rendered = renderer.renderRowDetailed(row, width, rc) - } - if rendered == "" { - continue - } - if shownAny && startsTurn(row.kind) { - builder.WriteString("\n") - } - if shownAny && previousKind == rowUser && row.kind == rowReasoning { - builder.WriteString("\n") - } - builder.WriteString(rendered) - builder.WriteString("\n") - shownAny = true - previousKind = row.kind + if m.altScreen && m.height > 0 { + return m.scrollableTranscriptItemsView(header, items, footer, width, "") } + // Popup/fallback: render the full body without viewport clipping. + layout := layoutTranscriptBodyItems(items) + return header + "\n" + layout.String() + "\n" + footer +} - if !shownAny { - builder.WriteString(zeroTheme.faint.Render("No transcript rows.")) - builder.WriteString("\n") +// detailedTranscriptFooter builds the one-line footer for the detailed transcript +// view: copy status when active, otherwise the key hint with jump-to-bottom cue. +func (m model) detailedTranscriptFooter(width int) string { + if copyStatus := strings.TrimSpace(m.copyStatus); copyStatus != "" { + return rightAlignedLine(zeroTheme.ink.Render(copyStatus), width) } - builder.WriteString(zeroTheme.line.Render(strings.Repeat("-", width))) - builder.WriteString("\n") - builder.WriteString(fitStyledLine(zeroTheme.faint.Render("Esc close | Ctrl+O toggle"), width)) - return builder.String() + detailKey := labelOr(m.keyBindings.toggleDetailed, "Ctrl+O") + hint := zeroTheme.faint.Render(fmt.Sprintf("Esc close | %s toggle", detailKey)) + if jt := m.jumpToBottomHint(); jt != "" { + return fitStyledLine(joinHeaderLine(hint, jt, width), width) + } + return fitStyledLine(hint, width) } func detailedTranscriptHeader(width int) string { diff --git a/internal/tui/transcript_view_test.go b/internal/tui/transcript_view_test.go index 355a2f2f..b34a8270 100644 --- a/internal/tui/transcript_view_test.go +++ b/internal/tui/transcript_view_test.go @@ -124,6 +124,78 @@ func transcriptViewTestModel() model { return m } +func TestPgUpDownScrollsDetailedTranscript(t *testing.T) { + m := transcriptViewTestModel() + m.altScreen = true + // Fill the transcript enough to overflow the 30-row viewport. + for i := 0; i < 60; i++ { + m.transcript = append(m.transcript, transcriptRow{kind: rowUser, text: "line content", final: true}) + } + m.flushed = len(m.transcript) + + // Enter detailed mode. + updated, _ := m.Update(testKeyCtrl('o')) + m = updated.(model) + + if !m.transcriptDetailed { + t.Fatal("sanity check failed: Ctrl+O should enter detailed mode") + } + if m.chatScrollOffset != 0 { + t.Fatal("sanity check failed: detailed transcript should start at bottom") + } + + // PgUp reveals older content = positive scroll offset. + updated, _ = m.Update(testKey(tea.KeyPgUp)) + m = updated.(model) + if m.chatScrollOffset <= 0 { + t.Fatalf("PgUp in detailed mode should scroll toward older content, got chatScrollOffset=%d", m.chatScrollOffset) + } + + // PgDown scrolls back toward bottom. + updated, _ = m.Update(testKey(tea.KeyPgDown)) + m = updated.(model) + if m.chatScrollOffset != 0 { + t.Fatalf("PgDown after PgUp should return to bottom in detailed mode, got chatScrollOffset=%d", m.chatScrollOffset) + } +} + +func TestUpDownArrowsScrollDetailedTranscript(t *testing.T) { + m := transcriptViewTestModel() + m.altScreen = true + for i := 0; i < 60; i++ { + m.transcript = append(m.transcript, transcriptRow{kind: rowUser, text: "line content", final: true}) + } + m.flushed = len(m.transcript) + + updated, _ := m.Update(testKeyCtrl('o')) + m = updated.(model) + + if !m.transcriptDetailed { + t.Fatal("sanity check failed: Ctrl+O should enter detailed mode") + } + + // Arrow down scrolls one line toward newer content; at bottom it stays at 0. + updated, _ = m.Update(testKey(tea.KeyDown)) + m = updated.(model) + if m.chatScrollOffset != 0 { + t.Fatalf("KeyDown at bottom should stay at 0, got %d", m.chatScrollOffset) + } + + // Arrow up scrolls one line toward older content. + updated, _ = m.Update(testKey(tea.KeyUp)) + m = updated.(model) + if m.chatScrollOffset != 1 { + t.Fatalf("KeyUp should scroll one line up, got chatScrollOffset=%d", m.chatScrollOffset) + } + + // Arrow down scrolls back toward bottom. + updated, _ = m.Update(testKey(tea.KeyDown)) + m = updated.(model) + if m.chatScrollOffset != 0 { + t.Fatalf("KeyDown after KeyUp should return to bottom, got chatScrollOffset=%d", m.chatScrollOffset) + } +} + func numberedLines(count int) string { lines := make([]string, 0, count) for i := 1; i <= count; i++ {