mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* fix(local): subchat mouse selection + hidden plan panel + scroll-extend selection Three bugs in the subagent/swarm drill-in (subchat) view and mouse text selection, all from the same root: newer features (subchat's row-source swap, wheel-scroll) were never integrated into the older selection/footer code. 1) Can't copy anything in the subchat view. transcriptLineAtMouse (and selectedTranscriptText, the copy-time text extraction) always hit-tested against m.transcript (the parent), never m.subchat.childRows, even while the screen was showing the child session. New transcriptHitTestSource() mirrors transcriptView's own branching (nav bar + child rows + full width vs pinned title bar + parent rows + sidebar-aware width) and is now the single source both functions use. 2) Plan panel shown above the composer inside the subchat view. footerView unconditionally rendered the PARENT run's pinned plan panel; only pendingAskUser was gated. Added a !m.subchat.active guard (m.plan belongs to the parent run, not whatever subagent/swarm session is being viewed). 3) Selection didn't extend across a scroll (reported as "stopped at the end of the screen"). transcriptSelection.cursor was only ever updated by a mouseMotion event; a wheel event is a distinct message type that never reached that code, so scrolling while selecting left the selection frozen at whatever line the mouse last physically moved over. New scrollChatExtendingSelection scrolls AND re-evaluates the cursor at the mouse's current position against the post-scroll viewport. Uses a new nearestTranscriptLineAtMouse (falls back to the closest visible selectable line) rather than an exact match, since the recomputed position can land exactly on a blank spacer row between messages after an odd-length scroll shifts text/spacer parity - an exact-match miss there must not silently no-op the extension. Tests: subchat selection resolves the CHILD session's text, not the parent's; the plan panel is absent from the footer during subchat; a selection's cursor bodyY moves (extends) after a wheel-scroll instead of staying pinned. Full gate green (73 pkg, -race). * feat(local): highlight clickable rows on mouse hover Moving the cursor over an interactive row now highlights it (accent underline) before you click, across every clickable surface: - specialist cards and collapse/expand toggle headers in the transcript - AGENTS sidebar rows (swarm members) - PLAN sidebar step rows - permission-prompt options (reuses the EXISTING keyboard-cursor highlight by moving pendingPermission.cursor on hover, instead of new styling) Requires switching the mouse-tracking mode from CellMotion (only reports motion while a button is held) to AllMotion (reports idle cursor movement too) - the existing 15ms mouse-event throttle already covers the added motion events. hover.go: hoverTarget + mouseHover predicate + updateHoverTarget, which reuses the SAME hit-testers and priority order as the existing click handlers so hover and click never disagree about what's clickable. Sidebar hover is resolved by STABLE IDENTITY (sessionID / stepIndex), not a cached line offset: hoveredSidebarLineOffset re-matches the identity against a freshly computed hit list on every render, so a row that's disappeared (a swarm member's linger elapsed, a plan step completed) simply doesn't highlight rather than a coincidentally-matching unrelated row lighting up. Transcript hover stays bodyY-keyed (matching the existing selection-highlight convention) with explicit clearHover() calls wherever bodyY numbering can shift without an intervening mouse motion: wheel-scroll, PgUp/PgDn, window resize, and transcript rebuilds (/clear, /resume, /rewind, /compact via resetFlushFrontier). Adversarial review caught two real bugs before ship, both fixed: - BLOCKER: plan-step hover stored the step's own index (0,1,2...) but the sidebar render treated it as a raw line offset into the rendered rows - every plan-step hover highlighted the wrong row (the AGENTS header/body). Fixed by resolving fresh at render time via the stable-identity redesign above, which also closes the related sidebar-drift issue. - MAJOR: hover wasn't cleared on keyboard PgUp/PgDn scroll. Tests: predicate correctness; hover resolves the right target across all 4 surfaces (specialist card, toggle row, sidebar agent, plan step, permission option) with the correct priority order; hover changes the actual render output (transcript + sidebar); hover clears on wheel-scroll and subchat exit; a regression test proving hoveredSidebarLineOffset self-heals (no highlight) when a hovered row's identity no longer resolves - the exact scenario the blocker fix targets. Full gate green (73 pkg, -race). * fix(local): soften hover color + auto-scroll a drag past the transcript edge 1) Hover highlight was brand-lime (bright, glaring against the near-black surface) and re-paints on every mouse movement. Switched to amber (an existing semantic token, "light orange") and dropped the underline per feedback. 2) Dragging a text selection past the top/bottom edge of the visible transcript now auto-scrolls toward that side and extends the selection to follow, matching the browser/editor "drag past the viewport edge" pattern. transcriptEdgeScrollDelta detects the drag is vertically outside the body rect (but still within its horizontal span, so it isn't just "off to the side" over the sidebar); dragToEdgeScroll scrolls one step and anchors the cursor to whichever line now sits at that edge of the NEW viewport - deliberately not a re-resolve of the drag's physical mouse position, which stays outside the body rect after scrolling (scrolling changes what content occupies a screen row, not the mouse's screen position) and would keep finding nothing. Extracted nearestTranscriptSelectableAt as the shared "closest selectable line to a target bodyY" core, now used by both nearestTranscriptLineAtMouse (target derived from a mouse position that may have landed on a spacer row) and dragToEdgeScroll (target derived directly from the viewport bound, no mouse position involved at all). Tests: dragging past the top edge scrolls toward older content and extends the cursor upward; the symmetric bottom-edge case scrolls toward newer content and extends downward. Full gate green (73 pkg, -race). * fix(local): smooth-glide the drag-to-edge auto-scroll instead of jumping The initial edge-scroll implementation (previous commit) applied a single chatWheelScrollLines (5-line) jump per raw mouse-motion event, which reads as sudden/jerky since motion events during a real drag arrive in bursts, and stops dead the instant the physical mouse holds still even while still parked past the edge (a terminal only reports motion on actual cursor movement). Replaced with a self-scheduling tea.Tick chain (dragEdgeScrollTickMsg, gated by edgeScrollSeq mirroring the existing exitConfirmSeq/copyStatusSeq idiom) that steps a small, fixed increment (dragEdgeScrollStep=1 line) on a short cadence (dragEdgeScrollInterval=70ms) for as long as the drag holds past the edge, independent of whether new raw motion events keep arriving. reducedMotion still gets the original single-bigger-step-per-event behavior (no animated glide). Adversarial review of the new self-perpetuating tick chain (the risk being an orphaned/stuck chain) found one real bug: a keypress mid-drag clears m.transcriptSelection.active directly (the KeyPressMsg handler in model.go) without going through stopEdgeScroll, leaving edgeScrollDelta stale. The in-flight tick itself terminates safely via its own liveness check - the bug was that the STALE nonzero edgeScrollDelta then fooled startEdgeScroll's "already running" fast path on the NEXT same-direction drag into silently doing nothing (no immediate step, no scheduled tick) for that whole hold. Fixed at the press site (transcript_selection.go's mouseLeftPress case): every fresh press now resets glide state via stopEdgeScroll, so a new drag always starts clean regardless of how the previous one ended - a single targeted fix rather than chasing every place transcriptSelection can be cleared. Tests: top/bottom edge glide starts and extends the cursor over several ticks (a single small step can land on a blank spacer row between messages, same as real usage); the chain stops when the drag returns to the body and a stale in-flight tick after that is a safe no-op; the keypress-interrupt regression (reproduces the adversarial review's finding, confirms the fix). Full gate green (73 pkg, -race). * fix(local): soften the brand-lime accent color across the whole UI The accent color (#caff3f) was fully saturated at fairly high lightness (HSL: L 62%, S 100%) - the kind of value that reads as neon/glaring everywhere it filled a block or bold-text surface, not just the one spot originally flagged: the composer's "typed" prompt glyph, the permission popup's focused-option badge, and the text-selection highlight all use it, plus the spinner, the brand chip, tool-card prompts, and more. Rather than patch each of those consumers individually, softened the single palette source (dark theme only; the light theme's accent is a separate, already-darkened value for its own contrast needs, untouched) to L 50% / S 80% (#ade619) - still clearly on-brand lime, and contrast against both black (onAccent text on accent fills) and the near-black panel stays far above WCAG AA (~14:1 and ~13:1, both comfortably over the 4.5:1 floor). Updated TestStreamingFadePaletteMonotonic's pinned RGB assertion (it checked the fade ramp's first bucket equals the literal old accent bytes) to the new values. Full gate green (73 pkg, -race; make lint/go vet clean except an unrelated stray file that isn't part of this branch or git history at all). * fix(local): darken the brand-lime accent further per live feedback Two prior passes (#ade619 at L50/S80, #8db620 at L42/S70) were each tested live against the original #caff3f via raw truecolor escape codes and confirmed too subtle in turn. Settled at L36/S62 (#759523) - a noticeably darker, more olive lime, confirmed against the original side by side. Contrast vs black (onAccent) and the near-black panel stays comfortably above WCAG AA (~6.1:1 and ~5.6:1). Updated the streaming-fade test's pinned RGB assertion to match. * fix: address PR #350 review comments and CI smoke test failures Smoke tests (macOS + Ubuntu): the 4 new edge-glide tests implicitly relied on m.reducedMotion being false, but mouseTestModel() inherits it from defaultReducedMotion(), which forces true whenever colorprofile.NoTTY is detected - true in CI's non-TTY test runners, false in a local interactive dev shell. Set reducedMotion = false explicitly in each of the 4 tests so they're deterministic regardless of environment. Verified by re-running locally under ZERO_REDUCED_MOTION=1 (reproduces the CI condition) - passes. CodeRabbit actionable comment: handleTranscriptSelectionMouse's mouseMotion case gated only on transcriptSelection.active, which stays true through the async copy-command grace window after release (a non-empty selection isn't reset until transcriptCopiedMsg lands) - so a genuine hover motion arriving in that window would be misrouted as a drag update. The suggested fix (gate on msg's own Button field) turned out to be wrong in a way only a full test run caught: TestTranscriptSelectionUpdatesOnGenericMotion is a pre-existing, deliberate test - some terminals don't restate the button on every motion event during a real drag, so trusting the per-event Button field breaks those terminals. Fixed properly instead: added a `dragging` field to transcriptSelectionState, tracking the app's own press/release bracket (true at press, false at release) independent of active and independent of any single event's Button field. mouseMotion now gates on dragging. CodeRabbit nitpicks: routed the two remaining raw `m.hover = hoverTarget{}` subchat-exit assignments through the existing clearHover() helper, so hover invalidation stays in one place. Left the third nitpick (hit-test layout recomputed per hover-motion tick) as a documented follow-up per the reviewer's own "trivial/poor tradeoff" framing - a proper per-frame memoized cache needs correct invalidation on scroll/resize/content-change to avoid reintroducing the exact staleness class of bug already fixed twice in this branch, and isn't worth rushing. New tests: TestTranscriptHoverAfterReleaseDoesNotMoveSelection (the actual bug CodeRabbit found - a post-release hover must not alter the just-copied selection). Full gate green (73 pkg, -race); TestTranscriptSelectionUpdatesOnGenericMotion (the test the first fix attempt broke) verified still passing.
1411 lines
54 KiB
Go
1411 lines
54 KiB
Go
package tui
|
|
|
|
import (
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
"github.com/atotto/clipboard"
|
|
"github.com/charmbracelet/x/ansi"
|
|
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
)
|
|
|
|
type transcriptSelectionPoint struct {
|
|
bodyY int
|
|
x int
|
|
}
|
|
|
|
type transcriptSelectionState struct {
|
|
active bool
|
|
// dragging is true from press until release — narrower than active, which
|
|
// stays true through the async copy-command grace window after release (a
|
|
// non-empty selection isn't reset to {} until transcriptCopiedMsg lands).
|
|
// mouseMotion gates on THIS, not active, so a genuine hover motion arriving
|
|
// in that post-release window doesn't get misrouted as a drag continuation.
|
|
// It also deliberately does NOT require msg's own Button field to be
|
|
// non-None on each motion event: some terminals don't restate the button on
|
|
// every motion report during a real drag (see
|
|
// TestTranscriptSelectionUpdatesOnGenericMotion), so trusting the
|
|
// per-event button field would break those terminals — dragging tracks the
|
|
// app's own press/release bracket instead.
|
|
dragging bool
|
|
anchor transcriptSelectionPoint
|
|
cursor transcriptSelectionPoint
|
|
}
|
|
|
|
type transcriptSelectableLine struct {
|
|
bodyY int
|
|
rowIndex int
|
|
textStart int
|
|
text string
|
|
toggle bool
|
|
live bool
|
|
// permOption marks a clickable permission-popup choice; permChoice is the
|
|
// decision a left-click on this row resolves. These rows carry no selectable
|
|
// text (they are buttons, not content).
|
|
permOption bool
|
|
permChoice permissionDecision
|
|
// specialistCard marks a clickable specialist card row. specialistID is
|
|
// the childSessionID to drill into on click or Enter.
|
|
specialistCard bool
|
|
specialistID string
|
|
}
|
|
|
|
type transcriptCopiedMsg struct {
|
|
chars int
|
|
// err is set when neither the native clipboard nor the OSC52 fallback landed
|
|
// the copy, so the status line can report the failure instead of "Copied!".
|
|
err error
|
|
}
|
|
|
|
type transcriptCopyStatusExpiredMsg struct {
|
|
seq int
|
|
}
|
|
|
|
type transcriptBodyItemKind int
|
|
|
|
const (
|
|
transcriptBodyItemTitle transcriptBodyItemKind = iota
|
|
transcriptBodyItemEmpty
|
|
transcriptBodyItemSeparator
|
|
transcriptBodyItemRule
|
|
transcriptBodyItemRow
|
|
transcriptBodyItemPendingPrompt
|
|
transcriptBodyItemPendingInterim
|
|
transcriptBodyItemSpecReview
|
|
)
|
|
|
|
type transcriptBodyItem struct {
|
|
kind transcriptBodyItemKind
|
|
rowIndex int
|
|
heightCacheKey string
|
|
heightCacheStable bool
|
|
render func(startBodyY int) transcriptBodyRenderedItem
|
|
}
|
|
|
|
type transcriptBodyRenderedItem struct {
|
|
lines []string
|
|
selectable []transcriptSelectableLine
|
|
}
|
|
|
|
type transcriptBodyItemSpan struct {
|
|
kind transcriptBodyItemKind
|
|
rowIndex int
|
|
startY int
|
|
height int
|
|
}
|
|
|
|
type transcriptBodyLayout struct {
|
|
lines []string
|
|
selectable []transcriptSelectableLine
|
|
spans []transcriptBodyItemSpan
|
|
}
|
|
|
|
func (m model) transcriptBodyLayout(width int, emptyOverlay string) transcriptBodyLayout {
|
|
return layoutTranscriptBodyItems(m.transcriptBodyItems(width, emptyOverlay))
|
|
}
|
|
|
|
func (m model) transcriptBody(width int, emptyOverlay string) (string, []transcriptSelectableLine) {
|
|
layout := m.transcriptBodyLayout(width, emptyOverlay)
|
|
return layout.String(), layout.selectable
|
|
}
|
|
|
|
func (l transcriptBodyLayout) String() string {
|
|
return strings.Join(l.lines, "\n")
|
|
}
|
|
|
|
func (l transcriptBodyLayout) totalLines() int {
|
|
if len(l.spans) > 0 {
|
|
last := l.spans[len(l.spans)-1]
|
|
return last.startY + last.height
|
|
}
|
|
return len(l.lines)
|
|
}
|
|
|
|
func (l transcriptBodyLayout) visibleLines(window transcriptViewportWindow) []string {
|
|
start := clampInt(window.start, 0, len(l.lines))
|
|
end := clampInt(window.end, start, len(l.lines))
|
|
return append([]string(nil), l.lines[start:end]...)
|
|
}
|
|
|
|
// padTranscriptBodyLines left-indents transcript body rows when a non-zero
|
|
// gutter is configured. It is horizontal only — it never changes the line count,
|
|
// so the width-keyed height cache stays valid. Two-column mode right-pads the
|
|
// chat block to the column width in joinColumns; single-column leaves any
|
|
// remaining cells blank.
|
|
func padTranscriptBodyLines(lines []string, gutter int) []string {
|
|
if gutter <= 0 {
|
|
return lines
|
|
}
|
|
pad := strings.Repeat(" ", gutter)
|
|
for i := range lines {
|
|
lines[i] = pad + lines[i]
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// shiftSelectableX bumps each selectable line's textStart by the gutter so
|
|
// click-to-select and the highlight stay aligned with the indented glyphs (the
|
|
// text now begins gutter cells further right on screen).
|
|
func shiftSelectableX(lines []transcriptSelectableLine, gutter int) []transcriptSelectableLine {
|
|
if gutter <= 0 {
|
|
return lines
|
|
}
|
|
for i := range lines {
|
|
lines[i].textStart += gutter
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// finalizeTranscriptBodyRow indents a rendered row by the reading-column gutter,
|
|
// shifts its selectable spans to match, and THEN paints the selection highlight —
|
|
// so the highlight is computed in the same shifted coordinate the mouse maps to
|
|
// and lands exactly where the user selected (instead of gutter cells off).
|
|
func (m model) finalizeTranscriptBodyRow(rendered string, selectable []transcriptSelectableLine, gutter int, startBodyY int) transcriptBodyRenderedItem {
|
|
lines := padTranscriptBodyLines(viewLines(rendered), gutter)
|
|
shifted := shiftSelectableX(selectable, gutter)
|
|
if m.transcriptSelection.active {
|
|
lines = viewLines(m.renderRenderedSelection(strings.Join(lines, "\n"), shifted, startBodyY))
|
|
} else if m.hover.kind == hoverTranscript {
|
|
// Mutually exclusive with the selection highlight above: transcriptSelection
|
|
// stays active for the lifetime of a persisted (copied) selection, not just
|
|
// during the drag — so hover correctly stays suppressed over old selected
|
|
// text too, not only mid-drag.
|
|
lines = viewLines(m.renderHoverHighlight(strings.Join(lines, "\n"), shifted, startBodyY))
|
|
}
|
|
return transcriptBodyRenderedItem{lines: lines, selectable: shifted}
|
|
}
|
|
|
|
// renderHoverHighlight re-styles the WHOLE line at m.hover.bodyY in the hover
|
|
// accent (see renderRenderedSelection for the sibling selection-highlight logic,
|
|
// which this mirrors). Only a clickable line (a specialist card or a
|
|
// collapse/expand toggle header) is eligible — if the hovered bodyY no longer
|
|
// matches one (content shifted beneath a stale hover target), this is a no-op,
|
|
// which self-heals a hover left over from before a transcript update.
|
|
func (m model) renderHoverHighlight(rendered string, selectable []transcriptSelectableLine, startBodyY int) string {
|
|
index := m.hover.bodyY - startBodyY
|
|
if index < 0 {
|
|
return rendered
|
|
}
|
|
lines := viewLines(rendered)
|
|
if index >= len(lines) {
|
|
return rendered
|
|
}
|
|
matched := false
|
|
for _, line := range selectable {
|
|
if line.bodyY == m.hover.bodyY && (line.specialistCard || line.toggle) {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
return rendered
|
|
}
|
|
lines[index] = zeroTheme.hover.Render(ansi.Strip(lines[index]))
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m model) transcriptBodyItems(width int, emptyOverlay string) []transcriptBodyItem {
|
|
items := []transcriptBodyItem{}
|
|
// Transcript ROWS render at the full chat width; row/status glyphs provide
|
|
// structure without adding another body margin. Block items (title bar, empty
|
|
// state, prompts) keep the full column width below.
|
|
contentWidth := transcriptContentWidth(width)
|
|
gutter := transcriptGutter(width)
|
|
|
|
// The inline title bar prints once into scrollback on the first WindowSizeMsg;
|
|
// until then it renders managed so the surface never appears headless.
|
|
if m.titleBarInTranscriptBody() {
|
|
items = append(items, transcriptBlockBodyItem(transcriptBodyItemTitle, -1, m.titleBar(width)))
|
|
}
|
|
|
|
previousKind := rowWelcome
|
|
havePreviousKind := false
|
|
if m.transcriptEmpty() && !m.pending {
|
|
if emptyOverlay != "" {
|
|
items = append(items, transcriptBlockBodyItem(transcriptBodyItemEmpty, -1, m.emptyStateWithOverlay(width, emptyOverlay)))
|
|
} else {
|
|
items = append(items, transcriptBlockBodyItem(transcriptBodyItemEmpty, -1, m.emptyState(width)))
|
|
}
|
|
} else {
|
|
rc := buildRowContext(m.transcript)
|
|
shownAny := false
|
|
previousKind, havePreviousKind = previousVisibleTranscriptKind(m.transcript, m.flushed, rc)
|
|
specialistSummaryEmitted := false
|
|
for index := m.flushed; 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.
|
|
if row.kind == rowWelcome || rc.skip(row) {
|
|
continue
|
|
}
|
|
if isSuccessfulExploreResult(row) {
|
|
groupRows, groupIndices, nextIndex := collectExploreResultGroup(m.transcript, index, rc)
|
|
if len(groupRows) > 0 {
|
|
if (shownAny || m.flushedAny) && startsTurn(row.kind) {
|
|
items = append(items, transcriptBlankBodyItem())
|
|
}
|
|
if shownAny && havePreviousKind && needsSeparatorBeforeToolCard(previousKind, row.kind) {
|
|
items = append(items, transcriptBlankBodyItem())
|
|
}
|
|
firstRowIndex := groupIndices[0]
|
|
groupRowsCopy := append([]transcriptRow(nil), groupRows...)
|
|
groupIndicesCopy := append([]int(nil), groupIndices...)
|
|
block := m.renderExploreResultGroup(groupRowsCopy, contentWidth, rc)
|
|
items = append(items, transcriptBodyItem{
|
|
kind: transcriptBodyItemRow,
|
|
rowIndex: firstRowIndex,
|
|
heightCacheKey: transcriptBlockBodyHeightCacheKey(transcriptBodyItemRow, block),
|
|
heightCacheStable: true,
|
|
render: func(startBodyY int) transcriptBodyRenderedItem {
|
|
rendered := m.renderExploreResultGroup(groupRowsCopy, contentWidth, rc)
|
|
selectable := make([]transcriptSelectableLine, 0, len(groupIndicesCopy)+1)
|
|
for offset, line := range viewLines(rendered) {
|
|
rowIndex := firstRowIndex
|
|
if offset > 0 && offset-1 < len(groupIndicesCopy) {
|
|
rowIndex = groupIndicesCopy[offset-1]
|
|
}
|
|
if meta, ok := selectableLineFromRenderedLine(rowIndex, startBodyY+offset, line, false); ok {
|
|
selectable = append(selectable, meta)
|
|
}
|
|
}
|
|
return m.finalizeTranscriptBodyRow(rendered, selectable, gutter, startBodyY)
|
|
},
|
|
})
|
|
shownAny = true
|
|
previousKind = row.kind
|
|
havePreviousKind = true
|
|
index = nextIndex - 1
|
|
continue
|
|
}
|
|
}
|
|
// Blank-line separation before turns, including between flushed
|
|
// history and the first live row.
|
|
if (shownAny || m.flushedAny) && startsTurn(row.kind) {
|
|
if havePreviousKind && shouldRuleBeforeTurn(previousKind, row.kind) {
|
|
items = append(items, transcriptRuleBodyItem(contentWidth, gutter))
|
|
} else {
|
|
items = append(items, transcriptBlankBodyItem())
|
|
}
|
|
}
|
|
if (shownAny || (m.flushedAny && havePreviousKind)) && previousKind == rowUser && row.kind == rowReasoning {
|
|
items = append(items, transcriptBlankBodyItem())
|
|
}
|
|
// Breathing room between back-to-back tool cards in the same turn: a
|
|
// tool result collapses its call into one card, so consecutive cards
|
|
// would otherwise stack with no gap (the dense "wall" look). One blank
|
|
// line between them matches the reference agents. Turn-starters are
|
|
// separated above, so this only fires tool-card -> tool-card.
|
|
if shownAny && havePreviousKind && needsSeparatorBeforeToolCard(previousKind, row.kind) {
|
|
items = append(items, transcriptBlankBodyItem())
|
|
}
|
|
// A "Thought for X" reasoning header opens the next think→act group, so
|
|
// give it a blank above when it follows a tool card. Reasoning interleaves
|
|
// the tool cards (tool → thought → tool …), which breaks the
|
|
// tool-card→tool-card rule above; without this the groups pack into a wall.
|
|
if shownAny && havePreviousKind && row.kind == rowReasoning && isToolCardKind(previousKind) {
|
|
items = append(items, transcriptBlankBodyItem())
|
|
}
|
|
// The plan panel is no longer injected inline here — it is pinned
|
|
// above the composer (see footerView) so a streaming turn can't push
|
|
// it off-screen.
|
|
// Inject the specialist summary line once, before the first
|
|
// specialist card in this turn's contiguous group.
|
|
if row.kind == rowSpecialist && !specialistSummaryEmitted {
|
|
specialistSummaryEmitted = true
|
|
specialists := m.specialists.all()
|
|
if len(specialists) == 0 {
|
|
// Fall back to the specialist info carried by the
|
|
// transcript rows themselves (covers tests and any path
|
|
// where the tracker has been cleared).
|
|
for j := index; j < len(m.transcript); j++ {
|
|
r := m.transcript[j]
|
|
if r.kind != rowSpecialist || r.specialistInfo == nil {
|
|
break
|
|
}
|
|
specialists = append(specialists, *r.specialistInfo)
|
|
}
|
|
}
|
|
summary := renderSpecialistSummary(specialists, m.spinnerGlyph())
|
|
if summary != "" {
|
|
items = append(items, transcriptBlockBodyItem(transcriptBodyItemRow, -1, summary))
|
|
items = append(items, transcriptBlankBodyItem())
|
|
}
|
|
}
|
|
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,
|
|
rowIndex: rowIndex,
|
|
heightCacheKey: heightCacheKey,
|
|
heightCacheStable: heightCacheStable,
|
|
render: func(startBodyY int) transcriptBodyRenderedItem {
|
|
rendered, selectable := m.renderTranscriptRow(rowIndex, transcriptRow, contentWidth, rc, startBodyY)
|
|
return m.finalizeTranscriptBodyRow(rendered, selectable, gutter, startBodyY)
|
|
},
|
|
})
|
|
shownAny = true
|
|
previousKind = row.kind
|
|
havePreviousKind = true
|
|
}
|
|
// The plan panel is pinned above the composer (footerView), not injected
|
|
// into the scrolling body, so there is nothing to emit here anymore.
|
|
}
|
|
|
|
if m.pending {
|
|
pendingShowsAssistantText := m.pendingPermission == nil && m.pendingAskUser == nil && strings.TrimSpace(m.streamingText) != ""
|
|
if pendingShowsAssistantText && havePreviousKind && shouldRuleBeforeTurn(previousKind, rowAssistant) {
|
|
items = append(items, transcriptRuleBodyItem(contentWidth, gutter))
|
|
} else {
|
|
items = append(items, transcriptBlankBodyItem())
|
|
}
|
|
switch {
|
|
case m.pendingPermission != nil:
|
|
perm := m.pendingPermission
|
|
items = append(items, transcriptBodyItem{
|
|
kind: transcriptBodyItemPendingPrompt,
|
|
rowIndex: -1,
|
|
heightCacheStable: false, // the highlight changes with the cursor
|
|
render: func(startBodyY int) transcriptBodyRenderedItem {
|
|
block, offsets := renderFocusedPermissionPrompt(perm.request, perm.cursor, width)
|
|
options := permissionOptions(perm.request)
|
|
selectable := make([]transcriptSelectableLine, 0, len(offsets))
|
|
for index, offset := range offsets {
|
|
if index >= len(options) {
|
|
break
|
|
}
|
|
selectable = append(selectable, transcriptSelectableLine{
|
|
bodyY: startBodyY + offset,
|
|
rowIndex: -1,
|
|
permOption: true,
|
|
permChoice: options[index].choice,
|
|
})
|
|
}
|
|
return transcriptBodyRenderedItem{lines: viewLines(block), selectable: selectable}
|
|
},
|
|
})
|
|
case m.pendingAskUser != nil:
|
|
// The ask-user questionnaire renders in the composer/footer region
|
|
// (footerView), not as a scrolling transcript card — nothing to emit here.
|
|
default:
|
|
items = append(items, transcriptBodyItem{
|
|
kind: transcriptBodyItemPendingInterim,
|
|
rowIndex: -1,
|
|
render: func(startBodyY int) transcriptBodyRenderedItem {
|
|
// Streaming text shares the reading column so it doesn't snap
|
|
// width when the turn finalizes into a row.
|
|
return m.finalizeTranscriptBodyRow(m.interimBlock(contentWidth), m.renderSelectableStreamingReasoning(contentWidth, startBodyY), gutter, startBodyY)
|
|
},
|
|
})
|
|
}
|
|
}
|
|
if m.pendingSpecReview != nil {
|
|
items = append(items, transcriptBlankBodyItem())
|
|
items = append(items, transcriptBlockBodyItem(transcriptBodyItemSpecReview, -1, renderFocusedSpecReviewPrompt(*m.pendingSpecReview, width)))
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
func transcriptBlockBodyItem(kind transcriptBodyItemKind, rowIndex int, block string) transcriptBodyItem {
|
|
return transcriptBodyItem{
|
|
kind: kind,
|
|
rowIndex: rowIndex,
|
|
heightCacheKey: transcriptBlockBodyHeightCacheKey(kind, block),
|
|
heightCacheStable: true,
|
|
render: func(int) transcriptBodyRenderedItem {
|
|
return transcriptBodyRenderedItem{lines: viewLines(block)}
|
|
},
|
|
}
|
|
}
|
|
|
|
func transcriptBlankBodyItem() transcriptBodyItem {
|
|
return transcriptBodyItem{
|
|
kind: transcriptBodyItemSeparator,
|
|
rowIndex: -1,
|
|
heightCacheKey: "transcript-body-height:v1:separator",
|
|
heightCacheStable: true,
|
|
render: func(int) transcriptBodyRenderedItem {
|
|
return transcriptBodyRenderedItem{lines: []string{""}}
|
|
},
|
|
}
|
|
}
|
|
|
|
func transcriptRuleBodyItem(width int, gutter int) transcriptBodyItem {
|
|
if width < 1 {
|
|
width = 1
|
|
}
|
|
return transcriptBodyItem{
|
|
kind: transcriptBodyItemRule,
|
|
rowIndex: -1,
|
|
heightCacheKey: "transcript-body-height:v1:rule:" + strconv.Itoa(width) + ":" + strconv.Itoa(gutter),
|
|
heightCacheStable: true,
|
|
render: func(int) transcriptBodyRenderedItem {
|
|
rule := zeroTheme.line.Render(strings.Repeat("─", width))
|
|
return transcriptBodyRenderedItem{lines: padTranscriptBodyLines([]string{rule, ""}, gutter)}
|
|
},
|
|
}
|
|
}
|
|
|
|
func isSuccessfulExploreResult(row transcriptRow) bool {
|
|
return row.kind == rowToolResult && !row.expanded && row.status != tools.StatusError && isExploreTool(toolRowName(row))
|
|
}
|
|
|
|
func collectExploreResultGroup(rows []transcriptRow, start int, rc rowContext) ([]transcriptRow, []int, int) {
|
|
groupRows := []transcriptRow{}
|
|
groupIndices := []int{}
|
|
for index := start; index < len(rows); index++ {
|
|
row := rows[index]
|
|
if row.kind == rowWelcome || rc.skip(row) {
|
|
continue
|
|
}
|
|
if !isSuccessfulExploreResult(row) {
|
|
return groupRows, groupIndices, index
|
|
}
|
|
groupRows = append(groupRows, row)
|
|
groupIndices = append(groupIndices, index)
|
|
}
|
|
return groupRows, groupIndices, len(rows)
|
|
}
|
|
|
|
func (m model) renderExploreResultGroup(rows []transcriptRow, width int, rc rowContext) string {
|
|
if len(rows) == 0 {
|
|
return ""
|
|
}
|
|
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
|
|
body := make([]string, 0, len(rows))
|
|
for index, row := range rows {
|
|
marker := "├"
|
|
if index == len(rows)-1 {
|
|
marker = "└"
|
|
}
|
|
key := rcKey(row.runID, row.id)
|
|
body = append(body, exploreCardLine(toolRowName(row), rc.hints[key], rc.args[key], row.detail, width, opts, marker))
|
|
}
|
|
head := zeroTheme.green.Bold(true).Render("Explored")
|
|
return toolCard(head, zeroTheme.green.Render("•"), body, zeroTheme.faint.Render("▸ details"), zeroTheme.line, width)
|
|
}
|
|
|
|
// transcriptBodyItemsFromRows builds body items from an arbitrary set of
|
|
// transcript rows (used by the subchat drill-in view to render a child
|
|
// session's events). It mirrors the main loop's logic but operates on the
|
|
// provided rows instead of m.transcript, and skips pending/permission state.
|
|
func (m model) transcriptBodyItemsFromRows(rows []transcriptRow, width int) []transcriptBodyItem {
|
|
items := []transcriptBodyItem{}
|
|
if len(rows) == 0 {
|
|
items = append(items, transcriptBlockBodyItem(transcriptBodyItemEmpty, -1, "No events in this subagent session."))
|
|
return items
|
|
}
|
|
contentWidth := transcriptContentWidth(width)
|
|
gutter := transcriptGutter(width)
|
|
rc := buildRowContext(rows)
|
|
shownAny := false
|
|
var previousKind rowKind
|
|
havePreviousKind := false
|
|
for index, row := range rows {
|
|
if row.kind == rowWelcome || rc.skip(row) {
|
|
continue
|
|
}
|
|
if shownAny && startsTurn(row.kind) {
|
|
if havePreviousKind && shouldRuleBeforeTurn(previousKind, row.kind) {
|
|
items = append(items, transcriptRuleBodyItem(contentWidth, gutter))
|
|
} else {
|
|
items = append(items, transcriptBlankBodyItem())
|
|
}
|
|
}
|
|
if shownAny && havePreviousKind && previousKind == rowUser && row.kind == rowReasoning {
|
|
items = append(items, transcriptBlankBodyItem())
|
|
}
|
|
rowIndex, transcriptRow := index, row
|
|
items = append(items, transcriptBodyItem{
|
|
kind: transcriptBodyItemRow,
|
|
rowIndex: rowIndex,
|
|
heightCacheKey: "subchat:" + strconv.Itoa(index) + ":" + strconv.Itoa(contentWidth),
|
|
render: func(startBodyY int) transcriptBodyRenderedItem {
|
|
rendered, selectable := m.renderTranscriptRow(rowIndex, transcriptRow, contentWidth, rc, startBodyY)
|
|
return m.finalizeTranscriptBodyRow(rendered, selectable, gutter, startBodyY)
|
|
},
|
|
})
|
|
shownAny = true
|
|
previousKind = row.kind
|
|
havePreviousKind = true
|
|
}
|
|
return items
|
|
}
|
|
|
|
func layoutTranscriptBodyItems(items []transcriptBodyItem) transcriptBodyLayout {
|
|
layout := transcriptBodyLayout{}
|
|
for _, item := range items {
|
|
startY := len(layout.lines)
|
|
rendered := renderTranscriptBodyItem(item, startY)
|
|
layout.lines = append(layout.lines, rendered.lines...)
|
|
layout.selectable = append(layout.selectable, rendered.selectable...)
|
|
layout.spans = append(layout.spans, transcriptBodyItemSpan{
|
|
kind: item.kind,
|
|
rowIndex: item.rowIndex,
|
|
startY: startY,
|
|
height: len(rendered.lines),
|
|
})
|
|
}
|
|
return layout
|
|
}
|
|
|
|
func measureTranscriptBodyItems(items []transcriptBodyItem, cache *transcriptBodyHeightCache) transcriptBodyLayout {
|
|
layout := transcriptBodyLayout{}
|
|
startY := 0
|
|
for _, item := range items {
|
|
height := transcriptBodyItemHeight(item, cache)
|
|
layout.spans = append(layout.spans, transcriptBodyItemSpan{
|
|
kind: item.kind,
|
|
rowIndex: item.rowIndex,
|
|
startY: startY,
|
|
height: height,
|
|
})
|
|
startY += height
|
|
}
|
|
return layout
|
|
}
|
|
|
|
func layoutVisibleTranscriptBodyItems(items []transcriptBodyItem, metrics transcriptBodyLayout, window transcriptViewportWindow) transcriptBodyLayout {
|
|
layout := transcriptBodyLayout{spans: append([]transcriptBodyItemSpan(nil), metrics.spans...)}
|
|
if window.end <= window.start {
|
|
return layout
|
|
}
|
|
for index, item := range items {
|
|
if index >= len(metrics.spans) {
|
|
break
|
|
}
|
|
span := metrics.spans[index]
|
|
spanEnd := span.startY + span.height
|
|
if spanEnd <= window.start || span.startY >= window.end {
|
|
continue
|
|
}
|
|
rendered := renderTranscriptBodyItem(item, span.startY)
|
|
localStart := clampInt(window.start-span.startY, 0, len(rendered.lines))
|
|
localEnd := clampInt(window.end-span.startY, localStart, len(rendered.lines))
|
|
layout.lines = append(layout.lines, rendered.lines[localStart:localEnd]...)
|
|
for _, line := range rendered.selectable {
|
|
if line.bodyY >= window.start && line.bodyY < window.end {
|
|
layout.selectable = append(layout.selectable, line)
|
|
}
|
|
}
|
|
}
|
|
return layout
|
|
}
|
|
|
|
func transcriptBodyItemHeight(item transcriptBodyItem, cache *transcriptBodyHeightCache) int {
|
|
if item.heightCacheStable {
|
|
if height, ok := cache.get(item.heightCacheKey); ok {
|
|
return height
|
|
}
|
|
}
|
|
rendered := renderTranscriptBodyItem(item, 0)
|
|
height := len(rendered.lines)
|
|
if item.heightCacheStable {
|
|
cache.set(item.heightCacheKey, height)
|
|
}
|
|
return height
|
|
}
|
|
|
|
func renderTranscriptBodyItem(item transcriptBodyItem, startBodyY int) transcriptBodyRenderedItem {
|
|
if item.render == nil {
|
|
return transcriptBodyRenderedItem{}
|
|
}
|
|
return item.render(startBodyY)
|
|
}
|
|
|
|
func transcriptBlockBodyHeightCacheKey(kind transcriptBodyItemKind, block string) string {
|
|
var b strings.Builder
|
|
appendRenderCacheField(&b, "transcript-body-block-height-v1")
|
|
appendRenderCacheField(&b, strconv.Itoa(int(kind)))
|
|
appendRenderCacheField(&b, block)
|
|
return b.String()
|
|
}
|
|
|
|
func (m model) transcriptRowBodyHeightCacheKey(row transcriptRow, width int, rc rowContext) (string, bool) {
|
|
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
|
|
rowKey, stable := m.renderRowCacheKey(row, width, rc, opts, false)
|
|
if rowKey == "" {
|
|
return "", false
|
|
}
|
|
var b strings.Builder
|
|
appendRenderCacheField(&b, "transcript-body-row-height-v1")
|
|
appendRenderCacheField(&b, rowKey)
|
|
return b.String(), stable
|
|
}
|
|
|
|
func (m model) renderTranscriptRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
|
|
switch row.kind {
|
|
case rowUser:
|
|
return m.renderSelectableUserRow(rowIndex, row, width, startBodyY)
|
|
case rowAssistant:
|
|
return m.renderSelectableAssistantRow(rowIndex, row, width, startBodyY)
|
|
case rowReasoning:
|
|
return m.renderSelectableReasoningRow(rowIndex, row, width, startBodyY)
|
|
case rowSystem, rowError, rowToolCall, rowPermission, rowAskUser:
|
|
return m.renderSelectableRenderedRow(rowIndex, row, width, rc, startBodyY)
|
|
case rowToolResult:
|
|
return m.renderSelectableToolResultRow(rowIndex, row, width, rc, startBodyY)
|
|
case rowSpecialist:
|
|
return m.renderSelectableSpecialistRow(rowIndex, row, width, rc, startBodyY)
|
|
default:
|
|
return m.renderRow(row, width, rc), nil
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
if rendered == "" {
|
|
return "", nil
|
|
}
|
|
// The first rendered line is the clickable toggle header; carry its text so a
|
|
// selection dragged through it copies the label too (the toggle flag still
|
|
// expands/collapses on a direct click, resolved on press before selection).
|
|
allLines := viewLines(rendered)
|
|
header := transcriptSelectableLine{bodyY: startBodyY, rowIndex: rowIndex, toggle: true}
|
|
if len(allLines) > 0 {
|
|
if meta, ok := selectableLineFromRenderedLine(rowIndex, startBodyY, allLines[0], false); ok {
|
|
header.text = meta.text
|
|
header.textStart = meta.textStart
|
|
}
|
|
}
|
|
selectable := []transcriptSelectableLine{header}
|
|
selectable = append(selectable, selectableLinesFromRendered(rowIndex, rendered, startBodyY, 1)...)
|
|
// The selection highlight is painted once, at the body-item level, AFTER the
|
|
// reading-column gutter shift (finalizeTranscriptBodyRow) — in the same shifted
|
|
// coordinate the mouse maps to. Painting it here (unshifted) made the highlight
|
|
// land gutter cells off from where the user clicked.
|
|
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)
|
|
if rendered == "" {
|
|
return "", nil
|
|
}
|
|
selectable := selectableLinesFromRendered(rowIndex, rendered, startBodyY, 0)
|
|
// The selection highlight is painted once, at the body-item level, AFTER the
|
|
// reading-column gutter shift (finalizeTranscriptBodyRow) — in the same shifted
|
|
// coordinate the mouse maps to. Painting it here (unshifted) made the highlight
|
|
// land gutter cells off from where the user clicked.
|
|
return rendered, selectable
|
|
}
|
|
|
|
func selectableLinesFromRendered(rowIndex int, rendered string, startBodyY int, skipLeading int) []transcriptSelectableLine {
|
|
lines := viewLines(rendered)
|
|
boxed := renderedLinesHaveBoxBorder(lines)
|
|
selectable := make([]transcriptSelectableLine, 0, len(lines))
|
|
for index, line := range lines {
|
|
if index < skipLeading {
|
|
continue
|
|
}
|
|
meta, ok := selectableLineFromRenderedLine(rowIndex, startBodyY+index, line, boxed)
|
|
if ok {
|
|
selectable = append(selectable, meta)
|
|
}
|
|
}
|
|
return selectable
|
|
}
|
|
|
|
func selectableLineFromRenderedLine(rowIndex int, bodyY int, rendered string, boxed bool) (transcriptSelectableLine, bool) {
|
|
text := ansi.Strip(rendered)
|
|
if isTranscriptStructuralLine(text) {
|
|
return transcriptSelectableLine{}, false
|
|
}
|
|
|
|
textStart := 0
|
|
if strings.HasPrefix(text, "│ ") {
|
|
textStart = lipgloss.Width("│ ")
|
|
text = strings.TrimPrefix(text, "│ ")
|
|
if boxed && strings.HasSuffix(text, " │") {
|
|
text = strings.TrimSuffix(text, " │")
|
|
}
|
|
}
|
|
for _, prefix := range []string{" ├ ", " └ ", " │ "} {
|
|
if strings.HasPrefix(text, prefix) {
|
|
textStart += lipgloss.Width(prefix)
|
|
text = strings.TrimPrefix(text, prefix)
|
|
break
|
|
}
|
|
}
|
|
text = strings.TrimRight(text, " ")
|
|
if strings.TrimSpace(text) == "" {
|
|
return transcriptSelectableLine{}, false
|
|
}
|
|
return transcriptSelectableLine{
|
|
bodyY: bodyY,
|
|
rowIndex: rowIndex,
|
|
textStart: textStart,
|
|
text: text,
|
|
}, true
|
|
}
|
|
|
|
func renderedLinesHaveBoxBorder(lines []string) bool {
|
|
hasTop := false
|
|
hasBottom := false
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(ansi.Strip(line))
|
|
if strings.HasPrefix(trimmed, "╭") && strings.HasSuffix(trimmed, "╮") {
|
|
hasTop = true
|
|
}
|
|
if strings.HasPrefix(trimmed, "╰") && strings.HasSuffix(trimmed, "╯") {
|
|
hasBottom = true
|
|
}
|
|
}
|
|
return hasTop && hasBottom
|
|
}
|
|
|
|
func isTranscriptStructuralLine(text string) bool {
|
|
trimmed := strings.TrimSpace(text)
|
|
if trimmed == "" {
|
|
return true
|
|
}
|
|
for _, r := range trimmed {
|
|
switch r {
|
|
case '╭', '╮', '╰', '╯', '┌', '┐', '└', '┘', '─', '━':
|
|
continue
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (m model) renderRenderedSelection(rendered string, selectable []transcriptSelectableLine, startBodyY int) string {
|
|
if len(selectable) == 0 {
|
|
return rendered
|
|
}
|
|
byY := make(map[int]transcriptSelectableLine, len(selectable))
|
|
for _, line := range selectable {
|
|
// Toggle headers and specialist cards now carry text, so a selection
|
|
// dragged through them highlights and copies their content. Empty lines and
|
|
// permission buttons (no copyable content) stay excluded.
|
|
if line.text == "" || line.permOption {
|
|
continue
|
|
}
|
|
byY[line.bodyY] = line
|
|
}
|
|
lines := viewLines(rendered)
|
|
for index, line := range lines {
|
|
meta, ok := byY[startBodyY+index]
|
|
if !ok {
|
|
continue
|
|
}
|
|
lines[index] = m.renderTranscriptSelectableStyledLine(meta, line)
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m model) renderTranscriptSelectableStyledLine(line transcriptSelectableLine, styledLine string) string {
|
|
start, end, ok := m.selectedColumnsForTranscriptLine(line)
|
|
if !ok {
|
|
return styledLine
|
|
}
|
|
absoluteStart := line.textStart + start
|
|
absoluteEnd := line.textStart + end
|
|
lineWidth := ansi.StringWidth(styledLine)
|
|
absoluteStart = clampInt(absoluteStart, 0, lineWidth)
|
|
absoluteEnd = clampInt(absoluteEnd, absoluteStart, lineWidth)
|
|
prefix := ansi.Cut(styledLine, 0, absoluteStart)
|
|
selected := ansi.Strip(ansi.Cut(styledLine, absoluteStart, absoluteEnd))
|
|
suffix := ansi.Cut(styledLine, absoluteEnd, lineWidth)
|
|
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)
|
|
if rendered == "" || row.specialistInfo == nil {
|
|
return "", nil
|
|
}
|
|
lines := viewLines(rendered)
|
|
boxed := renderedLinesHaveBoxBorder(lines)
|
|
selectable := make([]transcriptSelectableLine, len(lines))
|
|
for i := range lines {
|
|
sl := transcriptSelectableLine{
|
|
bodyY: startBodyY + i,
|
|
rowIndex: rowIndex,
|
|
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
|
|
}
|
|
selectable[i] = sl
|
|
}
|
|
return rendered, selectable
|
|
}
|
|
|
|
func (m model) renderSelectableUserRow(rowIndex int, row transcriptRow, width int, startBodyY int) (string, []transcriptSelectableLine) {
|
|
contentWidth := userPromptContentWidth(width)
|
|
wrapped := wrapPlainText(row.text, maxInt(1, contentWidth))
|
|
selectable := make([]transcriptSelectableLine, 0, len(wrapped))
|
|
for index, line := range wrapped {
|
|
meta := transcriptSelectableLine{
|
|
bodyY: startBodyY + index + 1,
|
|
rowIndex: rowIndex,
|
|
textStart: lipgloss.Width(userPromptPrefix),
|
|
text: line,
|
|
}
|
|
selectable = append(selectable, meta)
|
|
}
|
|
// The selection highlight is painted once, at the body-item level, AFTER the
|
|
// reading-column gutter shift (finalizeTranscriptBodyRow) — in the same shifted
|
|
// coordinate the mouse maps to. Self-painting here (unshifted) double-painted the
|
|
// highlight gutter cells off from the finalize pass (the "two highlights" bug).
|
|
return m.renderRow(row, width, rowContext{}), selectable
|
|
}
|
|
|
|
func (m model) renderSelectableAssistantRow(rowIndex int, row transcriptRow, width int, startBodyY int) (string, []transcriptSelectableLine) {
|
|
tableMeasure := width
|
|
measure := assistantMeasure(width)
|
|
textStart := 0
|
|
// Committed/selectable row: highlight (the result is cached per row).
|
|
wrapped := renderAssistantMarkdownText(row.text, measure, tableMeasure, true)
|
|
selectable := make([]transcriptSelectableLine, 0, len(wrapped))
|
|
for index, line := range wrapped {
|
|
plainLine := stripMarkdownRenderControls(line)
|
|
meta := transcriptSelectableLine{
|
|
bodyY: startBodyY + index,
|
|
rowIndex: rowIndex,
|
|
textStart: textStart,
|
|
text: plainLine,
|
|
}
|
|
selectable = append(selectable, meta)
|
|
}
|
|
// The selection highlight is painted once, at the body-item level, AFTER the
|
|
// reading-column gutter shift (finalizeTranscriptBodyRow) — in the same shifted
|
|
// coordinate the mouse maps to. Self-painting here (unshifted) double-painted the
|
|
// highlight gutter cells off from the finalize pass (the "two highlights" bug).
|
|
return m.renderRow(row, width, rowContext{}), selectable
|
|
}
|
|
|
|
func (m model) renderSelectableReasoningRow(rowIndex int, row transcriptRow, width int, startBodyY int) (string, []transcriptSelectableLine) {
|
|
lines, selectable := m.renderSelectableReasoningBlock(rowIndex, row.text, row.expanded, false, row.turnElapsed, width, startBodyY)
|
|
return strings.Join(lines, "\n"), selectable
|
|
}
|
|
|
|
func (m model) renderSelectableStreamingReasoning(width int, startBodyY int) []transcriptSelectableLine {
|
|
_, selectable := m.renderSelectableReasoningBlock(-1, m.streamingReasoning, m.streamingReasoningExpanded, true, 0, width, startBodyY)
|
|
for index := range selectable {
|
|
selectable[index].live = true
|
|
}
|
|
return selectable
|
|
}
|
|
|
|
func (m model) renderSelectableReasoningBlock(rowIndex int, text string, expanded bool, running bool, elapsed time.Duration, width int, startBodyY int) ([]string, []transcriptSelectableLine) {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" {
|
|
return nil, nil
|
|
}
|
|
headerPlain := reasoningHeaderText(text, expanded, running, elapsed)
|
|
header := reasoningHeaderLine(text, expanded, running, elapsed)
|
|
headerMeta := transcriptSelectableLine{
|
|
bodyY: startBodyY,
|
|
rowIndex: rowIndex,
|
|
textStart: 0,
|
|
text: headerPlain,
|
|
toggle: true,
|
|
}
|
|
// Selection highlight is painted once at the body-item level (finalizeTranscriptBodyRow),
|
|
// in the gutter-shifted coordinate the mouse maps to — never self-painted here.
|
|
headerRendered := header
|
|
lines := []string{headerRendered}
|
|
selectable := []transcriptSelectableLine{headerMeta}
|
|
if expanded {
|
|
renderedLines := renderReasoningBodyLines(text, width)
|
|
plainLines := renderAssistantMarkdownPlainText(text, maxInt(16, sayMeasure(width)-2), maxInt(16, sayMeasure(width)-2))
|
|
// A LIVE reasoning block is capped to ~half the screen so its toggle header
|
|
// stays on-screen. This MUST mirror renderReasoningBlock exactly (same cap,
|
|
// same marker line) so the selectable spans stay line-aligned with the
|
|
// displayed lines that finalizeTranscriptBodyRow highlights.
|
|
bodyOffset := 1
|
|
if running {
|
|
if bodyCap := m.liveReasoningBodyCap(); bodyCap > 0 && len(renderedLines) > bodyCap {
|
|
hidden := len(renderedLines) - bodyCap
|
|
selectable = append(selectable, transcriptSelectableLine{bodyY: startBodyY + 1, rowIndex: rowIndex, textStart: 2})
|
|
lines = append(lines, fitStyledLine(" "+reasoningHiddenMarker(hidden), width))
|
|
renderedLines = renderedLines[hidden:]
|
|
if hidden < len(plainLines) {
|
|
plainLines = plainLines[hidden:]
|
|
} else {
|
|
plainLines = nil
|
|
}
|
|
bodyOffset = 2
|
|
}
|
|
}
|
|
for index, line := range renderedLines {
|
|
plainLine := ""
|
|
if index < len(plainLines) {
|
|
plainLine = plainLines[index]
|
|
}
|
|
meta := transcriptSelectableLine{
|
|
bodyY: startBodyY + index + bodyOffset,
|
|
rowIndex: rowIndex,
|
|
textStart: 2,
|
|
text: plainLine,
|
|
}
|
|
selectable = append(selectable, meta)
|
|
rendered := styleAssistantMarkdownLine(line, zeroTheme.sayText)
|
|
lines = append(lines, fitStyledLine(" "+rendered, width))
|
|
}
|
|
}
|
|
return lines, selectable
|
|
}
|
|
|
|
func (m model) selectedColumnsForTranscriptLine(line transcriptSelectableLine) (int, int, bool) {
|
|
if !m.transcriptSelection.active {
|
|
return 0, 0, false
|
|
}
|
|
startPoint, endPoint := orderedTranscriptSelectionPoints(m.transcriptSelection.anchor, m.transcriptSelection.cursor)
|
|
if line.bodyY < startPoint.bodyY || line.bodyY > endPoint.bodyY {
|
|
return 0, 0, false
|
|
}
|
|
lineStart := line.textStart
|
|
lineEnd := line.textStart + lipgloss.Width(line.text)
|
|
start := lineStart
|
|
end := lineEnd
|
|
if line.bodyY == startPoint.bodyY {
|
|
start = clampInt(startPoint.x, lineStart, lineEnd)
|
|
}
|
|
if line.bodyY == endPoint.bodyY {
|
|
end = clampInt(endPoint.x, lineStart, lineEnd)
|
|
}
|
|
if end <= start {
|
|
return 0, 0, false
|
|
}
|
|
return start - line.textStart, end - line.textStart, true
|
|
}
|
|
|
|
func orderedTranscriptSelectionPoints(a transcriptSelectionPoint, b transcriptSelectionPoint) (transcriptSelectionPoint, transcriptSelectionPoint) {
|
|
if a.bodyY < b.bodyY || a.bodyY == b.bodyY && a.x <= b.x {
|
|
return a, b
|
|
}
|
|
return b, a
|
|
}
|
|
|
|
func splitPlainAtDisplayWidth(text string, width int) (string, string) {
|
|
if width <= 0 {
|
|
return "", text
|
|
}
|
|
used := 0
|
|
for index, glyph := range text {
|
|
glyphWidth := lipgloss.Width(string(glyph))
|
|
if used+glyphWidth > width {
|
|
return text[:index], text[index:]
|
|
}
|
|
used += glyphWidth
|
|
}
|
|
return text, ""
|
|
}
|
|
|
|
// transcriptHitTestSource returns the header/body-items/width mouse hit-testing
|
|
// must use to match what's actually on screen. It mirrors transcriptView's own
|
|
// branching exactly: the subchat drill-in swaps BOTH the header (nav bar instead
|
|
// of the pinned title bar) and the row source (the child session's rows instead
|
|
// of the parent transcript) — and drops the sidebar-reserved width, since subchat
|
|
// is always single-column. Hit-testing against the wrong source is why mouse
|
|
// 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.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
|
|
}
|
|
|
|
// transcriptHitTestBlocked reports whether mouse hit-testing must be skipped
|
|
// outright — a modal/overlay is up, or there's no alt-screen viewport at all.
|
|
func (m model) transcriptHitTestBlocked() bool {
|
|
return !m.altScreen || m.height <= 0 || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive()
|
|
}
|
|
|
|
// transcriptHitTestLayout computes the frame/window/layout mouse hit-testing needs,
|
|
// shared by transcriptLineAtMouse (exact match) and nearestTranscriptLineAtMouse
|
|
// (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))
|
|
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
|
window = transcriptViewportForLayout(metrics, frame, m.chatScrollOffset).window()
|
|
layout = layoutVisibleTranscriptBodyItems(items, metrics, window)
|
|
return frame, window, layout
|
|
}
|
|
|
|
func (m model) transcriptLineAtMouse(msg tea.MouseMsg) (transcriptSelectableLine, bool) {
|
|
if m.transcriptHitTestBlocked() {
|
|
return transcriptSelectableLine{}, false
|
|
}
|
|
frame, window, layout := m.transcriptHitTestLayout()
|
|
_, localY, ok := frame.bodyRect.local(mouseX(msg), mouseY(msg))
|
|
if !ok {
|
|
return transcriptSelectableLine{}, false
|
|
}
|
|
bodyY := window.start + localY
|
|
for _, line := range layout.selectable {
|
|
if line.bodyY != bodyY {
|
|
continue
|
|
}
|
|
if mouseX(msg) < 0 {
|
|
return transcriptSelectableLine{}, false
|
|
}
|
|
return line, true
|
|
}
|
|
return transcriptSelectableLine{}, false
|
|
}
|
|
|
|
// nearestTranscriptLineAtMouse is transcriptLineAtMouse with a fallback for
|
|
// scroll-driven selection extension: the recomputed on-screen position can land
|
|
// exactly on a non-selectable spacer row between messages (blank lines aren't in
|
|
// layout.selectable; a scroll can shift which physical row is text vs spacer), where
|
|
// an EXACT match finds nothing even though real text sits one row away. Falls back
|
|
// to the closest visible selectable line by bodyY, so the selection still extends
|
|
// instead of silently freezing for that scroll tick. Only used for scroll
|
|
// extension — a plain click keeps using transcriptLineAtMouse's exact match, since
|
|
// "nothing there" is a meaningful, correct result for an intentional click below
|
|
// the last line or in dead space.
|
|
func (m model) nearestTranscriptLineAtMouse(msg tea.MouseMsg) (transcriptSelectableLine, bool) {
|
|
if line, ok := m.transcriptLineAtMouse(msg); ok {
|
|
return line, true
|
|
}
|
|
if m.transcriptHitTestBlocked() {
|
|
return transcriptSelectableLine{}, false
|
|
}
|
|
frame, window, layout := m.transcriptHitTestLayout()
|
|
_, localY, ok := frame.bodyRect.local(mouseX(msg), mouseY(msg))
|
|
if !ok || mouseX(msg) < 0 {
|
|
return transcriptSelectableLine{}, false
|
|
}
|
|
return nearestTranscriptSelectableAt(layout, window.start+localY)
|
|
}
|
|
|
|
// nearestTranscriptSelectableAt returns the selectable line in layout closest to
|
|
// targetBodyY (ties go to whichever is found first) — the fallback core shared by
|
|
// nearestTranscriptLineAtMouse (target derived from a mouse position that may
|
|
// have shifted onto a non-selectable spacer row) and the edge-auto-scroll drag
|
|
// case (target derived directly from the new viewport's top/bottom bound, which
|
|
// has no "mouse position" to speak of at all).
|
|
func nearestTranscriptSelectableAt(layout transcriptBodyLayout, targetBodyY int) (transcriptSelectableLine, bool) {
|
|
best := transcriptSelectableLine{}
|
|
bestDist := -1
|
|
found := false
|
|
for _, line := range layout.selectable {
|
|
dist := line.bodyY - targetBodyY
|
|
if dist < 0 {
|
|
dist = -dist
|
|
}
|
|
if !found || dist < bestDist {
|
|
best, bestDist, found = line, dist, true
|
|
}
|
|
}
|
|
return best, found
|
|
}
|
|
|
|
// transcriptEdgeScrollDelta reports the scroll delta to apply when a drag has
|
|
// moved past the top or bottom edge of the visible transcript body while
|
|
// staying within its horizontal span — the classic "drag past the viewport
|
|
// edge auto-scrolls" affordance from browsers/editors. Positive reveals OLDER
|
|
// content (dragged above the top edge, same sign convention as wheel-up);
|
|
// negative reveals NEWER content (dragged below the bottom edge). ok is false
|
|
// when the point isn't a vertical-edge case at all (e.g. off to the side, over
|
|
// the sidebar) — that's not an edge-scroll, just outside the column.
|
|
func (m model) transcriptEdgeScrollDelta(msg tea.MouseMsg) (int, bool) {
|
|
if m.transcriptHitTestBlocked() {
|
|
return 0, false
|
|
}
|
|
frame, _, _ := m.transcriptHitTestLayout()
|
|
x, y := mouseX(msg), mouseY(msg)
|
|
if x < frame.bodyRect.x || x >= frame.bodyRect.x+frame.bodyRect.width {
|
|
return 0, false
|
|
}
|
|
if y < frame.bodyRect.y {
|
|
return chatWheelScrollLines, true
|
|
}
|
|
if y >= frame.bodyRect.y+frame.bodyRect.height {
|
|
return -chatWheelScrollLines, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// dragToEdgeScroll scrolls one step toward the edge the drag moved past, then
|
|
// extends the selection cursor to whichever line now sits at THAT edge of the
|
|
// new viewport, at column x. It deliberately does NOT try to re-resolve the
|
|
// drag's (still off-screen) physical mouse position — after scrolling, that
|
|
// position is still outside frame.bodyRect (scrolling changes what content
|
|
// occupies a screen row, not the mouse's screen position), so
|
|
// nearestTranscriptLineAtMouse would keep finding nothing. Anchoring to the
|
|
// viewport edge instead is what makes the selection visibly keep pace with the
|
|
// drag while the mouse holds past the edge. Takes a plain column rather than a
|
|
// tea.MouseMsg because the smooth-glide tick chain (dragEdgeScrollTickMsg) calls
|
|
// this repeatedly with no real mouse event of its own — see edgeScrollMouseX.
|
|
func (m model) dragToEdgeScroll(delta int, x int) model {
|
|
m = m.scrollChat(delta)
|
|
_, window, layout := m.transcriptHitTestLayout()
|
|
target := window.start
|
|
if delta < 0 {
|
|
target = window.start + window.height - 1
|
|
}
|
|
if line, ok := nearestTranscriptSelectableAt(layout, target); ok {
|
|
m.transcriptSelection.cursor = transcriptSelectionPointForMouse(line, x)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// dragEdgeScrollTickCmd schedules the next step of the smooth-glide edge-scroll,
|
|
// gated by seq (see dragEdgeScrollTickMsg / edgeScrollSeq).
|
|
func dragEdgeScrollTickCmd(seq int) tea.Cmd {
|
|
return tea.Tick(dragEdgeScrollInterval, func(time.Time) tea.Msg {
|
|
return dragEdgeScrollTickMsg{seq: seq}
|
|
})
|
|
}
|
|
|
|
// startEdgeScroll (re)starts the smooth-glide tick chain in the given direction —
|
|
// a no-op if it's already running the SAME direction (a fresh raw motion event
|
|
// arriving mid-glide must not reset the cadence, or repeated events faster than
|
|
// dragEdgeScrollInterval would make it jerky again, defeating the point). x is
|
|
// remembered for the ticks, which carry no mouse position of their own. Applies
|
|
// one immediate step so there's no perceptible delay between crossing the edge
|
|
// and the first visible movement.
|
|
func (m model) startEdgeScroll(direction int, x int) (model, tea.Cmd) {
|
|
step := dragEdgeScrollStep
|
|
if direction < 0 {
|
|
step = -step
|
|
}
|
|
if m.edgeScrollDelta == step {
|
|
m.edgeScrollMouseX = x
|
|
return m, nil
|
|
}
|
|
m = m.dragToEdgeScroll(step, x)
|
|
m.edgeScrollDelta = step
|
|
m.edgeScrollMouseX = x
|
|
m.edgeScrollSeq++
|
|
return m, dragEdgeScrollTickCmd(m.edgeScrollSeq)
|
|
}
|
|
|
|
// stopEdgeScroll ends the smooth-glide tick chain (the drag moved back into the
|
|
// body, off to the side, or released). Bumping edgeScrollSeq invalidates any
|
|
// tick already in flight, so it lands as a no-op and doesn't reschedule itself.
|
|
func (m model) stopEdgeScroll() model {
|
|
if m.edgeScrollDelta == 0 {
|
|
return m
|
|
}
|
|
m.edgeScrollDelta = 0
|
|
m.edgeScrollSeq++
|
|
return m
|
|
}
|
|
|
|
func (m model) transcriptViewportStart(body string, width int) (int, int, int) {
|
|
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
|
|
return transcriptViewportStartForFrame(body, frame, m.chatScrollOffset)
|
|
}
|
|
|
|
func transcriptViewportStartForLayout(layout transcriptBodyLayout, frame transcriptFrameLayout, scrollOffset int) (int, int, int) {
|
|
window := transcriptViewportForLayout(layout, frame, scrollOffset).window()
|
|
return window.start, window.height, frame.bodyRect.y
|
|
}
|
|
|
|
func transcriptViewportStartForFrame(body string, frame transcriptFrameLayout, scrollOffset int) (int, int, int) {
|
|
window := transcriptViewportForBody(body, frame, scrollOffset).window()
|
|
return window.start, window.height, frame.bodyRect.y
|
|
}
|
|
|
|
func transcriptSelectionPointForMouse(line transcriptSelectableLine, x int) transcriptSelectionPoint {
|
|
lineEnd := line.textStart + lipgloss.Width(line.text)
|
|
return transcriptSelectionPoint{
|
|
bodyY: line.bodyY,
|
|
x: clampInt(x, line.textStart, lineEnd),
|
|
}
|
|
}
|
|
|
|
func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd, bool) {
|
|
switch {
|
|
case mouseLeftPress(msg):
|
|
// A click on a clickable AGENTS sidebar row drills into that swarm member's
|
|
// session, reusing the specialist-card subchat path. Checked before the
|
|
// transcript hit-test since the sidebar is outside the chat column.
|
|
if hit, ok := m.sidebarLineAtMouse(msg); ok {
|
|
if errMsg := m.subchat.enter(m.sessionStore, hit.sessionID, hit.title, m.chatScrollOffset); errMsg != "" {
|
|
m = m.appendSystemNotice(errMsg)
|
|
}
|
|
m.chatScrollOffset = 0
|
|
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
|
|
return m, nil, true
|
|
}
|
|
// A click on a PLAN step row drops a transcript card listing the file
|
|
// changes captured while that step was in progress.
|
|
if stepIndex, ok := m.planStepAtMouse(msg); ok {
|
|
var cmd tea.Cmd
|
|
m, cmd = m.openPlanStepDetail(stepIndex)
|
|
return m, cmd, true
|
|
}
|
|
line, ok := m.transcriptLineAtMouse(msg)
|
|
if !ok {
|
|
if m.transcriptSelection.active {
|
|
m.transcriptSelection = transcriptSelectionState{}
|
|
return m, nil, true
|
|
}
|
|
return m, nil, false
|
|
}
|
|
if line.permOption {
|
|
// A left-click on a permission-popup option resolves it directly.
|
|
next, cmd := m.resolvePermission(line.permChoice)
|
|
return next.(model), cmd, true
|
|
}
|
|
if line.specialistCard {
|
|
// Click on a specialist card drills into its child session.
|
|
title := m.specialistTitleFor(line.specialistID)
|
|
if errMsg := m.subchat.enter(m.sessionStore, line.specialistID, title, m.chatScrollOffset); errMsg != "" {
|
|
m = m.appendSystemNotice(errMsg)
|
|
}
|
|
m.chatScrollOffset = 0
|
|
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
|
|
return m, nil, true
|
|
}
|
|
if line.toggle {
|
|
if line.live {
|
|
m.streamingReasoningExpanded = !m.streamingReasoningExpanded
|
|
} else {
|
|
m = m.toggleTranscriptRow(line.rowIndex)
|
|
}
|
|
return m, nil, true
|
|
}
|
|
point := transcriptSelectionPointForMouse(line, mouseX(msg))
|
|
m.copyStatus = ""
|
|
m.transcriptSelection = transcriptSelectionState{active: true, dragging: true, anchor: point, cursor: point}
|
|
// A fresh press always starts a brand-new drag: reset any glide state left
|
|
// over from a PREVIOUS selection that ended without going through
|
|
// stopEdgeScroll (e.g. a keypress mid-drag clears transcriptSelection.active
|
|
// directly, model.go's KeyPressMsg handler). Without this, a stale
|
|
// edgeScrollDelta can coincidentally match this new drag's direction and
|
|
// fool startEdgeScroll's "already running" fast path into silently doing
|
|
// nothing for the whole hold.
|
|
m = m.stopEdgeScroll()
|
|
return m, nil, true
|
|
case mouseMotion(msg):
|
|
// Gate on dragging (the app's own press/release bracket), not active
|
|
// alone: active stays true through the async copy-command grace window
|
|
// after release, so without this a genuine hover motion arriving in
|
|
// that window would be misrouted here and silently move the
|
|
// just-released selection instead of falling through to hover
|
|
// handling. Deliberately NOT gated on msg's own Button field — see
|
|
// transcriptSelectionState.dragging's doc comment.
|
|
if !m.transcriptSelection.dragging {
|
|
return m, nil, false
|
|
}
|
|
if line, ok := m.transcriptLineAtMouse(msg); ok {
|
|
m.transcriptSelection.cursor = transcriptSelectionPointForMouse(line, mouseX(msg))
|
|
m = m.stopEdgeScroll() // back inside the body: any running glide ends
|
|
return m, nil, true
|
|
}
|
|
// The drag moved past the top/bottom edge of the visible transcript:
|
|
// auto-scroll toward that side and extend the selection to follow.
|
|
delta, ok := m.transcriptEdgeScrollDelta(msg)
|
|
if !ok {
|
|
m = m.stopEdgeScroll() // off to the side (e.g. over the sidebar), not an edge case
|
|
return m, nil, true
|
|
}
|
|
if m.reducedMotion {
|
|
// No animated glide: a single, larger step per raw motion event,
|
|
// matching the app's reduced-motion convention elsewhere.
|
|
m = m.dragToEdgeScroll(delta, mouseX(msg))
|
|
return m, nil, true
|
|
}
|
|
direction := 1
|
|
if delta < 0 {
|
|
direction = -1
|
|
}
|
|
var cmd tea.Cmd
|
|
m, cmd = m.startEdgeScroll(direction, mouseX(msg))
|
|
return m, cmd, true
|
|
case mouseRelease(msg):
|
|
if !m.transcriptSelection.active {
|
|
return m, nil, false
|
|
}
|
|
m = m.stopEdgeScroll()
|
|
if line, ok := m.transcriptLineAtMouse(msg); ok {
|
|
m.transcriptSelection.cursor = transcriptSelectionPointForMouse(line, mouseX(msg))
|
|
}
|
|
text := m.selectedTranscriptText()
|
|
if strings.TrimSpace(text) == "" {
|
|
m.transcriptSelection = transcriptSelectionState{}
|
|
return m, nil, true
|
|
}
|
|
// The drag itself is over even though the selection stays active for
|
|
// the async copy-command grace window below — see dragging's doc
|
|
// comment on why mouseMotion must stop treating events as a drag now.
|
|
m.transcriptSelection.dragging = false
|
|
return m, copyTranscriptSelectionCmd(text), true
|
|
default:
|
|
return m, nil, false
|
|
}
|
|
}
|
|
|
|
// toggleTranscriptRow flips the collapse state of a collapsible row (a provider
|
|
// thought or a tool result card).
|
|
func (m model) toggleTranscriptRow(rowIndex int) model {
|
|
if rowIndex < 0 || rowIndex >= len(m.transcript) {
|
|
return m
|
|
}
|
|
switch m.transcript[rowIndex].kind {
|
|
case rowReasoning, rowToolResult:
|
|
m.transcript[rowIndex].expanded = !m.transcript[rowIndex].expanded
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m model) selectedTranscriptText() string {
|
|
// Must read from the SAME row source transcriptLineAtMouse hit-tested against
|
|
// (subchat vs parent transcript) — otherwise the selection's bodyY range is
|
|
// matched against the wrong layout here and copy silently returns nothing.
|
|
_, items, _ := m.transcriptHitTestSource()
|
|
layout := layoutTranscriptBodyItems(items)
|
|
parts := []string{}
|
|
for _, line := range layout.selectable {
|
|
start, end, ok := m.selectedColumnsForTranscriptLine(line)
|
|
if !ok {
|
|
continue
|
|
}
|
|
before, rest := splitPlainAtDisplayWidth(line.text, start)
|
|
_ = before
|
|
selected, _ := splitPlainAtDisplayWidth(rest, end-start)
|
|
parts = append(parts, selected)
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
func copyTranscriptSelectionCmd(text string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
// Prefer the native OS clipboard (pbcopy / clip.exe / xclip): it works on
|
|
// local terminals — including macOS Terminal.app, which has no OSC52 support
|
|
// at all — so the auto-copy-on-select actually lands on the clipboard. Fall
|
|
// back to OSC52 (forwarded by the terminal) for remote/SSH sessions where no
|
|
// local clipboard utility is reachable.
|
|
if err := clipboard.WriteAll(text); err != nil {
|
|
if _, oscErr := os.Stdout.WriteString(ansi.SetSystemClipboard(text)); oscErr != nil {
|
|
// Both paths failed; report it rather than claiming a copy that
|
|
// never reached any clipboard.
|
|
return transcriptCopiedMsg{err: err}
|
|
}
|
|
}
|
|
return transcriptCopiedMsg{chars: utf8.RuneCountInString(text)}
|
|
}
|
|
}
|