From 9e23a3e029429ce72c684a7184e9acda2bae163e Mon Sep 17 00:00:00 2001 From: N1xev Date: Sat, 4 Jul 2026 18:30:41 +0300 Subject: [PATCH 01/13] fix(tui): strip all ANSI escapes from copied transcript text renderAssistantMarkdownText embeds bold markers for prose and ANSI color sequences from chroma for fenced code blocks. stripMarkdownRenderControls only stripped the bold markers, leaking ANSI codes into clipboard text. Switch to ansi.Strip() to remove all escape sequences. --- internal/tui/assistant_markdown.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 { From 583193b007b605e36022f400cf84d81ac098ffee Mon Sep 17 00:00:00 2001 From: N1xev Date: Sat, 4 Jul 2026 18:49:40 +0300 Subject: [PATCH 02/13] refactor(tui): extract rendering dispatch and add detailed bool to transcriptBodyItems Extract rowRenderFn and transcriptRowDispatchFn types and refactor rendering methods into *Fn variants parameterized by the render function. Add detailed bool parameter to transcriptBodyItems to prepare for the detailed transcript viewport. All callers pass false so there is no behavior change yet. Extract transcriptRowBodyHeightCacheKeyOpts from transcriptRowBodyHeightCacheKey so the body cap can be configured per caller. --- internal/tui/file_view_test.go | 2 +- internal/tui/files_panel.go | 2 +- internal/tui/model.go | 6 +- internal/tui/model_test.go | 14 ++-- .../tui/specialist_summary_render_test.go | 2 +- internal/tui/transcript_body_test.go | 8 +- internal/tui/transcript_selection.go | 80 +++++++++++++------ 7 files changed, 74 insertions(+), 40 deletions(-) diff --git a/internal/tui/file_view_test.go b/internal/tui/file_view_test.go index ebc62adb..427db315 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)) } 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 964fe9b4..654cc4b8 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2216,7 +2216,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) @@ -2256,7 +2256,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 { @@ -2682,7 +2682,7 @@ func (m model) chatTranscriptViewport() (transcriptViewport, bool) { return transcriptViewport{}, false } width := m.chatColumnWidth() - items := m.transcriptBodyItems(width, "") + 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 55a4c26a..c909965e 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -2139,7 +2139,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 } @@ -2195,7 +2195,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) && @@ -2220,7 +2220,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) && @@ -2244,7 +2244,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) && @@ -2270,7 +2270,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) && @@ -2304,7 +2304,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 { @@ -2354,7 +2354,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/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..0d71cb3d 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,6 +241,8 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptB } else { rc := buildRowContext(m.transcript) shownAny := false + // The detailed view shows the full transcript from index 0, not + // the managed region after m.flushed. previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, m.flushed, rc) specialistSummaryEmitted := false for index := m.flushed; index < len(m.transcript); index++ { @@ -344,8 +346,6 @@ 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) items = append(items, transcriptBodyItem{ kind: transcriptBodyItemRow, @@ -635,8 +635,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 @@ -647,7 +656,22 @@ func (m model) transcriptRowBodyHeightCacheKey(row transcriptRow, width int, rc return b.String(), stable } +func (m model) transcriptRowBodyHeightCacheKey(row transcriptRow, width int, rc rowContext) (string, bool) { + return m.transcriptRowBodyHeightCacheKeyOpts(row, width, rc, cardBodyMaxLines) +} + 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 +680,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 +723,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 +861,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 +876,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 +885,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)) @@ -1035,7 +1069,7 @@ func (m model) transcriptHitTestSource() (header string, items []transcriptBodyI 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 From 2c571268ff20d21ee3ba00733f5a3aa1d37f676c Mon Sep 17 00:00:00 2001 From: N1xev Date: Sat, 4 Jul 2026 18:50:24 +0300 Subject: [PATCH 03/13] feat(tui): scrollable detailed transcript viewport with sidebar suppression Enable keyboard scrolling (PgUp/PgDown/Up/Down) in detailed transcript mode by routing through scrollChat instead of swallowing the keys. Rewrite detailedTranscriptView to use the transcriptBodyItems pipeline so the body renders through the same viewport/scroll engine as the live view. Implement detailedTranscriptFooter with copy-status and jump-to-bottom hint integration. Suppress the sidebar in detailed mode so the full chat width is available for reading uncapped tool output. --- internal/tui/model.go | 16 +++++-- internal/tui/sidebar.go | 2 +- internal/tui/transcript_selection.go | 32 +++++++++++--- internal/tui/transcript_view.go | 63 ++++++++++------------------ 4 files changed, 61 insertions(+), 52 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 654cc4b8..1b17f259 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1362,7 +1362,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { } case keyIs(msg, tea.KeyPgUp): if m.transcriptDetailed { - return m, nil + return m.scrollChat(m.chatPageScrollLines()), nil } // A stationary mouse over a bodyY-keyed transcript hover target would // otherwise stay lit at the scrolled-away bodyY until the next real @@ -1372,13 +1372,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m.scrollChat(m.chatPageScrollLines()), nil case keyIs(msg, tea.KeyPgDown): if m.transcriptDetailed { - return m, nil + return m.scrollChat(-m.chatPageScrollLines()), nil } m = m.clearHover() return m.scrollChat(-m.chatPageScrollLines()), nil case keyIs(msg, tea.KeyDown): if m.transcriptDetailed { - return m, nil + return m.scrollChat(-1), nil } if m.pendingPermission != nil { return m.movePermissionCursor(1), nil @@ -1420,7 +1420,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if m.transcriptDetailed { - return m, nil + return m.scrollChat(1), nil } if m.pendingPermission != nil { return m.movePermissionCursor(-1), nil @@ -2682,6 +2682,14 @@ func (m model) chatTranscriptViewport() (transcriptViewport, bool) { return transcriptViewport{}, false } width := m.chatColumnWidth() + 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)) diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index f43d014f..83618c47 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -54,7 +54,7 @@ func (m model) sidebarActive() 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/transcript_selection.go b/internal/tui/transcript_selection.go index 0d71cb3d..4a257b59 100644 --- a/internal/tui/transcript_selection.go +++ b/internal/tui/transcript_selection.go @@ -243,9 +243,17 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool shownAny := false // The detailed view shows the full transcript from index 0, not // the managed region after m.flushed. - previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, m.flushed, rc) + 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. @@ -346,14 +354,18 @@ func (m model) transcriptBodyItems(width int, emptyOverlay string, detailed bool } } rowIndex, transcriptRow := index, row - 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) }, }) @@ -1064,6 +1076,12 @@ 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 @@ -1083,7 +1101,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..cb49c698 100644 --- a/internal/tui/transcript_view.go +++ b/internal/tui/transcript_view.go @@ -14,55 +14,34 @@ 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() + hint := zeroTheme.faint.Render("Esc close | Ctrl+O toggle") + if jt := m.jumpToBottomHint(); jt != "" { + return fitStyledLine(joinHeaderLine(hint, jt, width), width) + } + return fitStyledLine(hint, width) } func detailedTranscriptHeader(width int) string { From 5c4966a578d2e2648781a894716a976b352bf93d Mon Sep 17 00:00:00 2001 From: N1xev Date: Sat, 4 Jul 2026 18:50:44 +0300 Subject: [PATCH 04/13] test(tui): detailed transcript scroll with PgUp/Down and Up/Down arrows TestPgUpDownScrollsDetailedTranscript verifies that PgUp scrolls toward older content and PgDown returns to the bottom in detailed mode. TestUpDownArrowsScrollDetailedTranscript verifies that arrow keys scroll one line at a time and bottom is clamped at offset 0. --- internal/tui/transcript_view_test.go | 65 ++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/internal/tui/transcript_view_test.go b/internal/tui/transcript_view_test.go index 355a2f2f..48858753 100644 --- a/internal/tui/transcript_view_test.go +++ b/internal/tui/transcript_view_test.go @@ -124,6 +124,71 @@ 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) + } +} + func numberedLines(count int) string { lines := make([]string, 0, count) for i := 1; i <= count; i++ { From e75ed4b83a0e7256bad7bbaa8109a570deda1798 Mon Sep 17 00:00:00 2001 From: N1xev Date: Sat, 4 Jul 2026 18:50:57 +0300 Subject: [PATCH 05/13] test(tui): ANSI strip verification for markdown rendering controls and selectable text TestStripMarkdownRenderControlsStripsAllANSI verifies bold markers, color sequences, and combined sequences are stripped. TestSelectedTranscriptTextStripsANSIFromHighlightedCode verifies that chroma-highlighted code blocks produce clean plain text in the selectable layer. --- internal/tui/rendering_lime_test.go | 44 +++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) 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{ From cc0ed3751750c1c6ff866f48ce2357630b42ebfd Mon Sep 17 00:00:00 2001 From: N1xev Date: Sat, 4 Jul 2026 19:45:48 +0300 Subject: [PATCH 06/13] fix for coderabit, and better fewer code. --- internal/tui/model.go | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index c3524656..d2995456 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1374,23 +1374,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } case keyIs(msg, tea.KeyPgUp): - if m.transcriptDetailed { - return m.scrollChat(m.chatPageScrollLines()), 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.scrollChat(-m.chatPageScrollLines()), nil - } m = m.clearHover() return m.scrollChat(-m.chatPageScrollLines()), nil case keyIs(msg, tea.KeyDown): if m.transcriptDetailed { + m = m.clearHover() return m.scrollChat(-1), nil } if m.pendingPermission != nil { From edb1fd2b4d1e9148735d93394f9ed270c9c2bbd2 Mon Sep 17 00:00:00 2001 From: N1xev Date: Sat, 4 Jul 2026 22:43:42 +0300 Subject: [PATCH 07/13] fix(tui): address PR review feedback dead code, hover clear, and rebindable key label --- internal/tui/model.go | 1 + internal/tui/transcript_selection.go | 3 --- internal/tui/transcript_view.go | 9 +++++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index d2995456..999d6595 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1424,6 +1424,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if m.transcriptDetailed { + m = m.clearHover() return m.scrollChat(1), nil } if m.pendingPermission != nil { diff --git a/internal/tui/transcript_selection.go b/internal/tui/transcript_selection.go index 4a257b59..b19c8284 100644 --- a/internal/tui/transcript_selection.go +++ b/internal/tui/transcript_selection.go @@ -668,9 +668,6 @@ func (m model) transcriptRowBodyHeightCacheKeyOpts(row transcriptRow, width int, return b.String(), stable } -func (m model) transcriptRowBodyHeightCacheKey(row transcriptRow, width int, rc rowContext) (string, bool) { - return m.transcriptRowBodyHeightCacheKeyOpts(row, width, rc, cardBodyMaxLines) -} 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) diff --git a/internal/tui/transcript_view.go b/internal/tui/transcript_view.go index cb49c698..98f7d4d0 100644 --- a/internal/tui/transcript_view.go +++ b/internal/tui/transcript_view.go @@ -1,6 +1,9 @@ package tui -import "strings" +import ( + "fmt" + "strings" +) func (m model) toggleDetailedTranscript() model { m.transcriptDetailed = !m.transcriptDetailed @@ -37,7 +40,9 @@ func (m model) detailedTranscriptFooter(width int) string { if copyStatus := strings.TrimSpace(m.copyStatus); copyStatus != "" { return rightAlignedLine(zeroTheme.ink.Render(copyStatus), width) } - hint := zeroTheme.faint.Render("Esc close | Ctrl+O toggle") + + 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) } From e182b7dac77eb9b94c52450eb9590be3b59b4708 Mon Sep 17 00:00:00 2001 From: N1xev Date: Sat, 4 Jul 2026 22:45:07 +0300 Subject: [PATCH 08/13] format --- internal/tui/transcript_selection.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/tui/transcript_selection.go b/internal/tui/transcript_selection.go index b19c8284..a41c67fc 100644 --- a/internal/tui/transcript_selection.go +++ b/internal/tui/transcript_selection.go @@ -668,7 +668,6 @@ func (m model) transcriptRowBodyHeightCacheKeyOpts(row transcriptRow, width int, return b.String(), stable } - 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) } From 5f29c6c1708aede714694d0ae21d85b0cda416de Mon Sep 17 00:00:00 2001 From: N1xev Date: Sat, 4 Jul 2026 23:42:47 +0300 Subject: [PATCH 09/13] addressing the review issue 1. the ctrl + b in detailed mode, added the guard to not be able to do it in detailed mode so it affect its state in the normal mode. 2. added a test coverage for keyDown after `keDown then keyUp` so it covers that. --- internal/tui/model.go | 2 +- internal/tui/transcript_view_test.go | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 999d6595..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 diff --git a/internal/tui/transcript_view_test.go b/internal/tui/transcript_view_test.go index 48858753..b34a8270 100644 --- a/internal/tui/transcript_view_test.go +++ b/internal/tui/transcript_view_test.go @@ -187,6 +187,13 @@ func TestUpDownArrowsScrollDetailedTranscript(t *testing.T) { 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 { From 80c23257d30096b0afa53bb293955496b46b6c17 Mon Sep 17 00:00:00 2001 From: N1xev Date: Sun, 5 Jul 2026 00:51:26 +0300 Subject: [PATCH 10/13] fix(tui): close file drill-in when entering detailed transcript toggleDetailedTranscript now calls exitFileView before returning, so an active file drill-in is automatically closed when the user enters detailed transcript mode. --- internal/tui/transcript_view.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/tui/transcript_view.go b/internal/tui/transcript_view.go index 98f7d4d0..53a9d788 100644 --- a/internal/tui/transcript_view.go +++ b/internal/tui/transcript_view.go @@ -8,6 +8,9 @@ import ( 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. From ad6cdb9df7661fe70359da9d07f30b75f96395c5 Mon Sep 17 00:00:00 2001 From: N1xev Date: Sun, 5 Jul 2026 00:51:33 +0300 Subject: [PATCH 11/13] test(tui): verify file drill-in closes on detailed toggle Adds TestDetailedTranscriptClosesFileView and TestDetailedTranscriptStaysClosedOnSecondToggle to guard against the file-view bypass bug. --- internal/tui/file_view_test.go | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/internal/tui/file_view_test.go b/internal/tui/file_view_test.go index 427db315..3856ea6d 100644 --- a/internal/tui/file_view_test.go +++ b/internal/tui/file_view_test.go @@ -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). From 789e8a2e4ecd8d75dfdcc6aa3635ffffd581d250 Mon Sep 17 00:00:00 2001 From: N1xev Date: Sun, 5 Jul 2026 00:51:39 +0300 Subject: [PATCH 12/13] fix(tui): block composer hit-test while detailed transcript is active composerMouseSelectionBlocked and mouseOverComposer now check m.transcriptDetailed so mouse clicks and wheel events in the composer area reach the transcript body instead of being silently consumed. --- internal/tui/composer.go | 2 +- internal/tui/mouse.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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() From 7020ddf6ea53bb6d788f754db551e507d45d44c8 Mon Sep 17 00:00:00 2001 From: N1xev Date: Sun, 5 Jul 2026 00:51:45 +0300 Subject: [PATCH 13/13] test(tui): verify composer mouse is ignored in detailed mode Adds TestComposerMouseSelectionBlockedInDetailedMode to guard against composer hit-test swallowing mouse events during detailed transcript viewing. --- internal/tui/mouse_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) 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)