mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e24203bfcb | ||
|
|
358e319ab3 | ||
|
|
523dd80311 | ||
|
|
bce63987d8 | ||
|
|
28ead26dfe | ||
|
|
ad723eb9e3 | ||
|
|
79464b1826 |
No files matched your search
@@ -139,6 +139,10 @@ func (m model) drainFlushQueue() (model, tea.Cmd) {
|
||||
// surface ended.
|
||||
func (m *model) resetFlushFrontier(divider string) {
|
||||
m.flushed = 0
|
||||
// Renumbers every row's bodyY from the top (used by /clear, /resume, /rewind,
|
||||
// /compact); a transcript-hover target's bodyY would otherwise risk
|
||||
// coincidentally matching an unrelated row in the rebuilt transcript.
|
||||
m.hover = hoverTarget{}
|
||||
if divider != "" && m.flushedAny {
|
||||
m.flushQueue = append(m.flushQueue, zeroTheme.faint.Render(divider))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package tui
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
// hoverKind discriminates which clickable surface a hoverTarget refers to.
|
||||
type hoverKind int
|
||||
|
||||
const (
|
||||
hoverNone hoverKind = iota
|
||||
// hoverTranscript: a specialist card or collapse/expand toggle header in the
|
||||
// main transcript body (or the subchat child view), identified by bodyY.
|
||||
hoverTranscript
|
||||
// hoverSidebarAgent: a clickable AGENTS sidebar row, identified by sessionID.
|
||||
hoverSidebarAgent
|
||||
// hoverPlanStep: a plan step row in the sidebar's PLAN section, identified by
|
||||
// step index.
|
||||
hoverPlanStep
|
||||
)
|
||||
|
||||
// hoverTarget identifies the single clickable row (if any) currently under the
|
||||
// mouse cursor with no button pressed. Exactly one of bodyY/sessionID/stepIndex is
|
||||
// meaningful, discriminated by kind.
|
||||
//
|
||||
// The sidebar fields deliberately store a STABLE IDENTITY (sessionID, stepIndex),
|
||||
// not a raw line offset: the AGENTS/PLAN section sizes can change between when a
|
||||
// hover is detected and when the sidebar next renders (a swarm member's linger
|
||||
// window elapsing, a plan step completing) with no mouse motion in between to
|
||||
// re-resolve it. A cached raw offset would then silently point at whatever
|
||||
// unrelated row now occupies that slot. hoveredSidebarLineOffset (sidebar.go)
|
||||
// re-resolves the CURRENT line offset for this identity fresh on every render, so
|
||||
// a row that's gone simply doesn't highlight rather than a wrong one lighting up.
|
||||
type hoverTarget struct {
|
||||
kind hoverKind
|
||||
bodyY int // hoverTranscript
|
||||
sessionID string // hoverSidebarAgent
|
||||
stepIndex int // hoverPlanStep
|
||||
}
|
||||
|
||||
// mouseHover reports whether msg is a plain cursor-movement event with NO button
|
||||
// pressed — a hover, not a drag. Requires AllMotion mouse reporting (see View's
|
||||
// wantsMouseCapture branch); CellMotion only reports motion while a button is held,
|
||||
// so this predicate would never match under the old mouse mode.
|
||||
func mouseHover(msg tea.MouseMsg) bool {
|
||||
if _, ok := msg.(tea.MouseMotionMsg); !ok {
|
||||
return false
|
||||
}
|
||||
return mouseEvent(msg).Button == tea.MouseNone
|
||||
}
|
||||
|
||||
// updateHoverTarget resolves what's under the cursor for a hover motion, using
|
||||
// the SAME hit-testers and priority order as the click handlers in
|
||||
// handleTranscriptSelectionMouse's press case: a sidebar row (agent, then plan
|
||||
// step) takes priority since it's outside the chat column, then a clickable
|
||||
// transcript line. Clears the hover when nothing clickable is under the cursor.
|
||||
func (m model) updateHoverTarget(msg tea.MouseMsg) model {
|
||||
if hit, ok := m.sidebarLineAtMouse(msg); ok {
|
||||
return m.withHover(hoverTarget{kind: hoverSidebarAgent, sessionID: hit.sessionID})
|
||||
}
|
||||
if stepIndex, ok := m.planStepAtMouse(msg); ok {
|
||||
return m.withHover(hoverTarget{kind: hoverPlanStep, stepIndex: stepIndex})
|
||||
}
|
||||
if line, ok := m.transcriptLineAtMouse(msg); ok {
|
||||
// A permission option reuses its OWN existing keyboard-cursor highlight
|
||||
// (see hoverPermissionOption) rather than the m.hover mechanism, so there's
|
||||
// nothing further to set here.
|
||||
if line.permOption {
|
||||
return m.hoverPermissionOption(line.permChoice).withHover(hoverTarget{})
|
||||
}
|
||||
// Only a card or a collapse/expand toggle header is "clickable" here; plain
|
||||
// selectable text (e.g. a user/assistant message) isn't, so hovering over
|
||||
// it must not light up as if it were.
|
||||
if line.specialistCard || line.toggle {
|
||||
return m.withHover(hoverTarget{kind: hoverTranscript, bodyY: line.bodyY})
|
||||
}
|
||||
}
|
||||
return m.withHover(hoverTarget{})
|
||||
}
|
||||
|
||||
// hoverPermissionOption moves the pending permission prompt's cursor to the
|
||||
// hovered option, reusing its EXISTING keyboard-navigation highlight (the same
|
||||
// one Tab/Shift+Tab/arrow keys move) instead of new styling — the popup already
|
||||
// renders whichever option .cursor points at differently. A no-op when no
|
||||
// permission prompt is pending or the choice isn't found (defensive; every
|
||||
// permOption line's choice always comes from permissionOptions(request) in the
|
||||
// first place).
|
||||
func (m model) hoverPermissionOption(choice permissionDecision) model {
|
||||
if m.pendingPermission == nil {
|
||||
return m
|
||||
}
|
||||
for index, option := range permissionOptions(m.pendingPermission.request) {
|
||||
if option.choice == choice {
|
||||
m.pendingPermission.cursor = index
|
||||
return m
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) withHover(t hoverTarget) model {
|
||||
m.hover = t
|
||||
return m
|
||||
}
|
||||
|
||||
// clearHover drops any hover highlight. Called wherever the underlying content a
|
||||
// hover target refers to may no longer mean the same thing on the next render — a
|
||||
// wheel-scroll (the cursor's mapping to a bodyY is only recomputed on the NEXT
|
||||
// motion event; without clearing, a stale bodyY could coincidentally match a
|
||||
// DIFFERENT row after the viewport shifts, highlighting the wrong thing) or a
|
||||
// subchat toggle (bodyY numbering is entirely different between the parent
|
||||
// transcript and a child session).
|
||||
func (m model) clearHover() model {
|
||||
if m.hover.kind == hoverNone {
|
||||
return m
|
||||
}
|
||||
m.hover = hoverTarget{}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
func TestMouseHoverPredicate(t *testing.T) {
|
||||
if !mouseHover(testMouseMotion(tea.MouseNone, 5, 5)) {
|
||||
t.Error("a motion event with no button pressed must be a hover")
|
||||
}
|
||||
if mouseHover(testMouseMotion(tea.MouseLeft, 5, 5)) {
|
||||
t.Error("a motion event WITH a button pressed is a drag, not a hover")
|
||||
}
|
||||
if mouseHover(testMouseClick(tea.MouseLeft, 5, 5)) {
|
||||
t.Error("a click is not a hover")
|
||||
}
|
||||
if mouseHover(testMouseWheel(tea.MouseWheelDown, 5, 5)) {
|
||||
t.Error("a wheel event is not a hover")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHoverTargetOnSpecialistCard(t *testing.T) {
|
||||
m := specialistClickTestModel(t, "child-sess-1")
|
||||
x, y, ok := specialistCardClickPoint(m, 2)
|
||||
if !ok {
|
||||
t.Fatal("expected a specialist card selectable line in the layout")
|
||||
}
|
||||
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x, y))
|
||||
m = updated.(model)
|
||||
if m.hover.kind != hoverTranscript {
|
||||
t.Fatalf("hover.kind = %v, want hoverTranscript over a specialist card", m.hover.kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHoverTargetOnToggleRow(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowReasoning, text: "private thought"})
|
||||
|
||||
width := m.chatColumnWidth()
|
||||
body, selectable := m.transcriptBody(width, "")
|
||||
start, _, top := m.transcriptViewportStart(body, width)
|
||||
var target transcriptSelectableLine
|
||||
for _, line := range selectable {
|
||||
if line.toggle {
|
||||
target = line
|
||||
break
|
||||
}
|
||||
}
|
||||
if !target.toggle {
|
||||
t.Fatalf("expected reasoning header to be clickable, selectable=%#v", selectable)
|
||||
}
|
||||
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, target.textStart, top+target.bodyY-start))
|
||||
m = updated.(model)
|
||||
if m.hover.kind != hoverTranscript {
|
||||
t.Fatalf("hover.kind = %v, want hoverTranscript over a toggle header", m.hover.kind)
|
||||
}
|
||||
if m.hover.bodyY != target.bodyY {
|
||||
t.Fatalf("hover.bodyY = %d, want %d", m.hover.bodyY, target.bodyY)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHoverTargetOnPlainTextIsNone(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.transcript = appendRow(m.transcript, rowUser, "hello world")
|
||||
textY := firstTranscriptTextMouseY(t, m)
|
||||
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, 3, textY))
|
||||
m = updated.(model)
|
||||
if m.hover.kind != hoverNone {
|
||||
t.Fatalf("hover.kind = %v, want hoverNone over plain (non-clickable) text", m.hover.kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHoverTargetOnSidebarAgentRow(t *testing.T) {
|
||||
// Only a SWARM member row (a session mapped via swarmSessionMap) is clickable
|
||||
// in the sidebar — a Task-delegation specialist row is not (sidebarAgentRows
|
||||
// only records hits from the swarm loop). swarmSidebarTestModel builds exactly
|
||||
// that: real conversation + a mapped swarm member session.
|
||||
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
|
||||
if !m.sidebarActive() {
|
||||
t.Fatal("sanity check failed: sidebar should be active with a swarm member present on a 100-col terminal")
|
||||
}
|
||||
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("expected at least one clickable sidebar agent row")
|
||||
}
|
||||
x0 := m.chatColumnWidth() + 3
|
||||
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
|
||||
m = updated.(model)
|
||||
if m.hover.kind != hoverSidebarAgent {
|
||||
t.Fatalf("hover.kind = %v, want hoverSidebarAgent", m.hover.kind)
|
||||
}
|
||||
if m.hover.sessionID != hits[0].sessionID {
|
||||
t.Fatalf("hover.sessionID = %q, want %q", m.hover.sessionID, hits[0].sessionID)
|
||||
}
|
||||
// The bug this guards: hoverTarget must store the STABLE identity (sessionID),
|
||||
// not a raw line offset — resolve it back to a line offset the same way the
|
||||
// render path does (hoveredSidebarLineOffset) and confirm it lands on the
|
||||
// SAME row that was actually hovered, not some other row at that raw index.
|
||||
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
|
||||
if !ok || resolved != hits[0].lineOffset {
|
||||
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHoverTargetOnPlanStep(t *testing.T) {
|
||||
m := runningPlanModel(t, 3)
|
||||
m.altScreen = true
|
||||
m.height = 40
|
||||
// sidebarActive requires real conversation; runningPlanModel only sets m.plan,
|
||||
// leaving the transcript at its default (welcome-only, i.e. "empty").
|
||||
m.transcript = appendRow(m.transcript, rowUser, "do something")
|
||||
if !m.sidebarActive() {
|
||||
t.Fatal("sanity check failed: sidebar should be active with a plan present on a 100-col terminal")
|
||||
}
|
||||
hits := m.sidebarPlanSelectables(sidebarWidth(m.width))
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("expected at least one clickable plan step row")
|
||||
}
|
||||
x0 := m.chatColumnWidth() + 3
|
||||
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
|
||||
m = updated.(model)
|
||||
if m.hover.kind != hoverPlanStep {
|
||||
t.Fatalf("hover.kind = %v, want hoverPlanStep", m.hover.kind)
|
||||
}
|
||||
if m.hover.stepIndex != hits[0].stepIndex {
|
||||
t.Fatalf("hover.stepIndex = %d, want %d", m.hover.stepIndex, hits[0].stepIndex)
|
||||
}
|
||||
// The bug this guards: the render path must resolve stepIndex back to the
|
||||
// CORRECT current lineOffset (hits[0].lineOffset, which is >= base=4, never a
|
||||
// raw small index like 0/1/2) — not treat stepIndex itself as a line offset
|
||||
// into the rendered sidebar (which would land on the AGENTS header/body).
|
||||
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
|
||||
if !ok || resolved != hits[0].lineOffset {
|
||||
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHoverTargetOnPermissionOptionMovesCursor(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.pending = true // the permission prompt only renders into the transcript body while m.pending
|
||||
m.pendingPermission = &pendingPermissionPrompt{
|
||||
request: agent.PermissionRequest{ToolName: "write_file"},
|
||||
cursor: 0,
|
||||
}
|
||||
width := m.chatColumnWidth()
|
||||
body, selectable := m.transcriptBody(width, "")
|
||||
start, _, top := m.transcriptViewportStart(body, width)
|
||||
var target transcriptSelectableLine
|
||||
found := false
|
||||
for _, line := range selectable {
|
||||
if line.permOption && line.permChoice != permissionOptions(m.pendingPermission.request)[0].choice {
|
||||
target = line
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected at least 2 permission options to test a non-default hover target")
|
||||
}
|
||||
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, target.textStart, top+target.bodyY-start))
|
||||
m = updated.(model)
|
||||
// Permission-option hover reuses the existing cursor highlight, not m.hover.
|
||||
if m.hover.kind != hoverNone {
|
||||
t.Fatalf("hover.kind = %v, want hoverNone (permission options use pendingPermission.cursor)", m.hover.kind)
|
||||
}
|
||||
wantIndex := -1
|
||||
for index, option := range permissionOptions(m.pendingPermission.request) {
|
||||
if option.choice == target.permChoice {
|
||||
wantIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if m.pendingPermission.cursor != wantIndex {
|
||||
t.Fatalf("pendingPermission.cursor = %d, want %d (the hovered option)", m.pendingPermission.cursor, wantIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoverChangesTranscriptRenderOutput(t *testing.T) {
|
||||
m := specialistClickTestModel(t, "child-sess-1")
|
||||
width := m.chatColumnWidth()
|
||||
without := m.transcriptBodyLayout(width, "").String()
|
||||
|
||||
x, y, ok := specialistCardClickPoint(m, 2)
|
||||
if !ok {
|
||||
t.Fatal("expected a specialist card selectable line in the layout")
|
||||
}
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x, y))
|
||||
m = updated.(model)
|
||||
if m.hover.kind != hoverTranscript {
|
||||
t.Fatal("sanity check failed: hover should be set over the specialist card")
|
||||
}
|
||||
with := m.transcriptBodyLayout(width, "").String()
|
||||
|
||||
if without == with {
|
||||
t.Fatal("rendering with a hover target set should differ from without (the highlight should show)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoverChangesSidebarRenderOutput(t *testing.T) {
|
||||
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
|
||||
width := m.chatColumnWidth()
|
||||
height := m.height
|
||||
without := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
|
||||
|
||||
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("expected at least one clickable sidebar agent row")
|
||||
}
|
||||
updated, _ := m.Update(testMouseMotion(tea.MouseNone, width+3+1, hits[0].lineOffset))
|
||||
m = updated.(model)
|
||||
if m.hover.kind != hoverSidebarAgent {
|
||||
t.Fatal("sanity check failed: hover should be set over the sidebar agent row")
|
||||
}
|
||||
with := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
|
||||
|
||||
if without == with {
|
||||
t.Fatal("sidebar rendering with a hover target set should differ from without (the highlight should show)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoverClearsOnWheelScroll(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
for i := 0; i < 80; i++ {
|
||||
m.transcript = appendRow(m.transcript, rowUser, "line content")
|
||||
}
|
||||
m.hover = hoverTarget{kind: hoverTranscript, bodyY: 5}
|
||||
|
||||
updated, _ := m.Update(testMouseWheel(tea.MouseWheelUp, 0, 3))
|
||||
m = updated.(model)
|
||||
if m.hover.kind != hoverNone {
|
||||
t.Fatalf("hover should clear on a wheel scroll, got %#v", m.hover)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoverClearsOnSubchatExit(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.subchat.active = true
|
||||
m.subchat.childSessionID = "child-1"
|
||||
m.subchat.childRows = appendRow(nil, rowUser, "child text")
|
||||
m.hover = hoverTarget{kind: hoverTranscript, bodyY: 5}
|
||||
|
||||
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEsc}))
|
||||
m = updated.(model)
|
||||
if m.subchat.active {
|
||||
t.Fatal("Esc should exit the subchat view")
|
||||
}
|
||||
if m.hover.kind != hoverNone {
|
||||
t.Fatalf("hover should clear on subchat exit (bodyY numbering differs), got %#v", m.hover)
|
||||
}
|
||||
}
|
||||
|
||||
// A sidebar row can disappear between when it was hovered and the next render
|
||||
// (a swarm member's linger window elapsing) with no mouse motion in between to
|
||||
// re-target the hover. hoveredSidebarLineOffset must not paint a highlight on
|
||||
// whatever unrelated row now occupies that identity's old slot — it must find
|
||||
// nothing and skip painting entirely.
|
||||
func TestHoveredSidebarLineOffsetSelfHealsWhenRowDisappears(t *testing.T) {
|
||||
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
|
||||
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("expected at least one clickable sidebar agent row")
|
||||
}
|
||||
m.hover = hoverTarget{kind: hoverSidebarAgent, sessionID: hits[0].sessionID}
|
||||
|
||||
// Simulate the member disappearing (a fresh model with no swarm member at all,
|
||||
// same identity no longer present) without any mouse motion clearing m.hover.
|
||||
m2 := swarmSidebarTestModel(t, nil)
|
||||
m2.hover = m.hover
|
||||
|
||||
if _, ok := m2.hoveredSidebarLineOffset(sidebarWidth(m2.width)); ok {
|
||||
t.Fatal("hoveredSidebarLineOffset should find nothing once the identity no longer resolves, not fall back to a stale/coincidental row")
|
||||
}
|
||||
}
|
||||
+75
-9
@@ -40,6 +40,13 @@ const chatWheelScrollLines = 5
|
||||
const ctrlCExitConfirmDuration = 3 * time.Second
|
||||
const ctrlCExitConfirmText = "Press Ctrl+C again to exit"
|
||||
|
||||
// dragEdgeScrollInterval/dragEdgeScrollStep drive the smooth-glide auto-scroll
|
||||
// while a drag holds past the transcript edge (see edgeScrollDelta). A small step
|
||||
// on a short, steady cadence reads as a smooth continuous scroll; the wheel-scroll
|
||||
// tick size (chatWheelScrollLines) would jump too far per step for that.
|
||||
const dragEdgeScrollInterval = 70 * time.Millisecond
|
||||
const dragEdgeScrollStep = 1
|
||||
|
||||
type model struct {
|
||||
ctx context.Context
|
||||
cwd string
|
||||
@@ -280,10 +287,31 @@ type model struct {
|
||||
// (clickable suggestions, right-click paste, transcript select) pause while on.
|
||||
mouseReleased bool
|
||||
transcriptSelection transcriptSelectionState
|
||||
copyStatus string
|
||||
copyStatusSeq int
|
||||
exitConfirmActive bool
|
||||
exitConfirmSeq int
|
||||
// hover identifies the single clickable row (if any) currently under the
|
||||
// mouse cursor with no button pressed, so it renders in a distinct style —
|
||||
// the visual cue that it's clickable. Requires AllMotion mouse reporting
|
||||
// (see wantsMouseCapture) since idle cursor movement carries no button.
|
||||
hover hoverTarget
|
||||
copyStatus string
|
||||
copyStatusSeq int
|
||||
exitConfirmActive bool
|
||||
exitConfirmSeq int
|
||||
// edgeScrollDelta drives the smooth-glide auto-scroll while a drag holds past
|
||||
// the transcript's top/bottom edge: 0 when idle, else the signed per-tick step
|
||||
// (matches transcriptEdgeScrollDelta's sign convention). A self-scheduling
|
||||
// tea.Tick chain (see dragEdgeScrollTickCmd) keeps stepping it at a fixed small
|
||||
// increment regardless of whether new raw mouse-motion events arrive — a
|
||||
// terminal only reports motion on actual cursor movement, so without a timer
|
||||
// the scroll would stop dead the instant the physical mouse holds still, even
|
||||
// while parked past the edge. edgeScrollSeq invalidates a stale in-flight tick
|
||||
// (mirroring exitConfirmSeq/copyStatusSeq) whenever the drag moves back into
|
||||
// the body, releases, or the chain is otherwise stopped.
|
||||
edgeScrollDelta int
|
||||
edgeScrollSeq int
|
||||
// edgeScrollMouseX is the column the tick chain keeps extending the selection
|
||||
// at — captured from the triggering drag since a timer tick carries no mouse
|
||||
// position of its own.
|
||||
edgeScrollMouseX int
|
||||
|
||||
// picker, when non-nil, is an open interactive selector overlay (/model,
|
||||
// /effort with no argument). It captures ↑/↓/Enter/Esc and applies
|
||||
@@ -330,6 +358,15 @@ type exitConfirmExpiredMsg struct {
|
||||
seq int
|
||||
}
|
||||
|
||||
// dragEdgeScrollTickMsg advances the smooth-glide auto-scroll one step (see
|
||||
// edgeScrollDelta). seq must match m.edgeScrollSeq or the tick is stale (the
|
||||
// drag moved back into the body, released, or was otherwise stopped since this
|
||||
// tick was scheduled) and is silently dropped — the self-scheduling chain simply
|
||||
// doesn't reschedule itself, so it terminates rather than ticking forever.
|
||||
type dragEdgeScrollTickMsg struct {
|
||||
seq int
|
||||
}
|
||||
|
||||
// toolCallStreamStartMsg / toolCallStreamDeltaMsg carry a tool call's live
|
||||
// argument stream from the agent goroutine to the update loop, so a file being
|
||||
// written renders as it streams (see streamingToolCallView).
|
||||
@@ -890,6 +927,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.exitConfirmActive = false
|
||||
}
|
||||
return m, nil
|
||||
case dragEdgeScrollTickMsg:
|
||||
if msg.seq != m.edgeScrollSeq || m.edgeScrollDelta == 0 || !m.transcriptSelection.active {
|
||||
return m, nil // stale, or the chain was stopped since this tick was scheduled
|
||||
}
|
||||
m = m.dragToEdgeScroll(m.edgeScrollDelta, m.edgeScrollMouseX)
|
||||
return m, dragEdgeScrollTickCmd(m.edgeScrollSeq)
|
||||
case providerWizardOAuthMsg:
|
||||
return m.applyProviderWizardOAuth(msg)
|
||||
case providerWizardDeviceCodeMsg:
|
||||
@@ -956,6 +999,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Subchat view exits on Esc (returns to main chat).
|
||||
if m.subchat.active {
|
||||
m.chatScrollOffset = m.subchat.exit()
|
||||
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
|
||||
return m, nil
|
||||
}
|
||||
if m.mcpCommandCancel != nil {
|
||||
@@ -1174,11 +1218,17 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
// A stationary mouse over a bodyY-keyed transcript hover target would
|
||||
// otherwise stay lit at the scrolled-away bodyY until the next real
|
||||
// motion event (see clearHover) — same reasoning as the wheel-scroll
|
||||
// cases in mouse.go.
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(m.chatPageScrollLines()), nil
|
||||
case keyIs(msg, tea.KeyPgDown):
|
||||
if m.transcriptDetailed {
|
||||
return m, nil
|
||||
}
|
||||
m = m.clearHover()
|
||||
return m.scrollChat(-m.chatPageScrollLines()), nil
|
||||
case keyIs(msg, tea.KeyDown):
|
||||
if m.transcriptDetailed {
|
||||
@@ -1220,6 +1270,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// ArrowUp exits subchat view (returns to main chat).
|
||||
if m.subchat.active {
|
||||
m.chatScrollOffset = m.subchat.exit()
|
||||
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
|
||||
return m, nil
|
||||
}
|
||||
if m.transcriptDetailed {
|
||||
@@ -1445,6 +1496,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
// A resize re-wraps content at a new width, shifting every row's bodyY;
|
||||
// a stale transcript-hover target could coincidentally land on an
|
||||
// unrelated clickable row (same reasoning as clearHover's other callers).
|
||||
m = m.clearHover()
|
||||
// Reset the streaming-text fade state. A width change can re-wrap
|
||||
// the in-progress text into a different number of visual lines,
|
||||
// which invalidates the per-line age mapping. The next delta
|
||||
@@ -1881,7 +1936,14 @@ func (m model) View() tea.View {
|
||||
view.AltScreen = m.altScreen
|
||||
view.ReportFocus = m.notifier != nil
|
||||
if m.wantsMouseCapture() {
|
||||
view.MouseMode = tea.MouseModeCellMotion
|
||||
// AllMotion (not CellMotion) is required for hover highlighting: it
|
||||
// reports cursor movement even with no button pressed. CellMotion only
|
||||
// reports motion while a button is held (drag) — see bubbletea's
|
||||
// MouseMode docs. AllMotion has marginally worse terminal compatibility
|
||||
// but is well supported by the terminals this app targets; the existing
|
||||
// 15ms mouse-event throttle (mouseEventThrottleInterval) already bounds
|
||||
// the redraw rate from the extra motion events.
|
||||
view.MouseMode = tea.MouseModeAllMotion
|
||||
}
|
||||
return view
|
||||
}
|
||||
@@ -2030,10 +2092,14 @@ func (m model) footerView(width int) string {
|
||||
// while the transcript scrolls underneath (a streaming turn no longer pushes
|
||||
// the plan off-screen). Budgeted to at most a third of the screen height; a
|
||||
// taller plan collapses to a one-line summary so the composer always stays
|
||||
// on screen.
|
||||
if plan := m.renderPinnedPlanPanel(width, m.pinnedPlanMaxHeight()); plan != "" {
|
||||
footer.WriteString(plan)
|
||||
footer.WriteString("\n")
|
||||
// on screen. Skipped in the subchat drill-in: m.plan belongs to the PARENT
|
||||
// run, not the subagent/swarm child session being viewed there, so pinning it
|
||||
// above that composer would show unrelated state.
|
||||
if !m.subchat.active {
|
||||
if plan := m.renderPinnedPlanPanel(width, m.pinnedPlanMaxHeight()); plan != "" {
|
||||
footer.WriteString(plan)
|
||||
footer.WriteString("\n")
|
||||
}
|
||||
}
|
||||
// The row above the composer: transient copy feedback takes priority; otherwise
|
||||
// a faint idle affordance — discoverable key hints on the left, a jump-to-bottom
|
||||
|
||||
+29
-2
@@ -14,6 +14,25 @@ type mouseSelectionTarget struct {
|
||||
Index int
|
||||
}
|
||||
|
||||
// scrollChatExtendingSelection scrolls the chat and, if a drag-selection is in
|
||||
// progress, extends its cursor to the mouse's current position against the
|
||||
// POST-scroll viewport — mirroring what a mouseMotion event does. Without this, a
|
||||
// selection's cursor is only ever updated by actual mouse movement (see
|
||||
// handleTranscriptSelectionMouse's mouseMotion case): a wheel-scroll event is a
|
||||
// distinct message type that never reaches that code, so scrolling while
|
||||
// selecting left the selection frozen at whatever line the mouse last physically
|
||||
// moved over — capped at the viewport that was visible before the scroll.
|
||||
func (m model) scrollChatExtendingSelection(delta int, msg tea.MouseMsg) model {
|
||||
m = m.scrollChat(delta)
|
||||
if !m.transcriptSelection.active {
|
||||
return m
|
||||
}
|
||||
if line, ok := m.nearestTranscriptLineAtMouse(msg); ok {
|
||||
m.transcriptSelection.cursor = transcriptSelectionPointForMouse(line, mouseX(msg))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
// While a provider-wizard OAuth login is in flight, ignore all mouse input
|
||||
// (clicks and wheel) so a stray scroll can't change the selected provider
|
||||
@@ -84,10 +103,17 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if next, cmd, ok := m.handleTranscriptSelectionMouse(msg); ok {
|
||||
return next, cmd
|
||||
}
|
||||
// A plain hover (cursor moved, no button pressed) never matches the
|
||||
// press/drag/release cases above, so it falls through here — resolve what's
|
||||
// under the cursor so it can render with the hover highlight.
|
||||
if mouseHover(msg) {
|
||||
return m.updateHoverTarget(msg), nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case mouseWheelUp(msg):
|
||||
m.clearMouseSelection()
|
||||
m = m.clearHover()
|
||||
if m.providerWizard != nil {
|
||||
m.providerWizard.move(-1)
|
||||
return m, nil
|
||||
@@ -116,9 +142,10 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
return next, nil
|
||||
}
|
||||
}
|
||||
return m.scrollChat(chatWheelScrollLines), nil
|
||||
return m.scrollChatExtendingSelection(chatWheelScrollLines, msg), nil
|
||||
case mouseWheelDown(msg):
|
||||
m.clearMouseSelection()
|
||||
m = m.clearHover()
|
||||
if m.providerWizard != nil {
|
||||
m.providerWizard.move(1)
|
||||
return m, nil
|
||||
@@ -147,7 +174,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
return next, nil
|
||||
}
|
||||
}
|
||||
return m.scrollChat(-chatWheelScrollLines), nil
|
||||
return m.scrollChatExtendingSelection(-chatWheelScrollLines, msg), nil
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -407,6 +407,245 @@ func TestTranscriptSelectionReleaseExtendsRangeWithoutMotion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A completed selection stays "active" through the async copy-command grace
|
||||
// window (transcriptCopiedMsg hasn't landed yet), but the drag itself is over.
|
||||
// A genuine hover motion (no button held) arriving in that window must NOT be
|
||||
// treated as a drag continuation — it must fall through to hover handling
|
||||
// instead of silently moving the selection the user just released.
|
||||
func TestTranscriptHoverAfterReleaseDoesNotMoveSelection(t *testing.T) {
|
||||
m := withSidebarContent(mouseTestModel())
|
||||
m.mouseCapture = true
|
||||
m.transcript = appendRow(m.transcript, rowUser, "hello world")
|
||||
textY := firstTranscriptTextMouseY(t, m)
|
||||
|
||||
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 3, textY))
|
||||
m = updated.(model)
|
||||
updated, cmd := m.Update(testMouseRelease(tea.MouseNone, 8, textY))
|
||||
m = updated.(model)
|
||||
if cmd == nil {
|
||||
t.Fatal("sanity check failed: release should return the copy command")
|
||||
}
|
||||
if m.transcriptSelection.dragging {
|
||||
t.Fatal("dragging must be false immediately after release")
|
||||
}
|
||||
before := m.selectedTranscriptText()
|
||||
|
||||
// A hover (no button) moves further along the same line, in the window
|
||||
// before transcriptCopiedMsg has landed (m.transcriptSelection.active is
|
||||
// still true here).
|
||||
updated, _ = m.Update(testMouseMotion(tea.MouseNone, 10, textY))
|
||||
m = updated.(model)
|
||||
|
||||
if got := m.selectedTranscriptText(); got != before {
|
||||
t.Fatalf("a post-release hover changed the selection: got %q, want unchanged %q", got, before)
|
||||
}
|
||||
}
|
||||
|
||||
// Dragging a selection past the top edge of the visible transcript must
|
||||
// auto-scroll toward older content and extend the selection to follow — the
|
||||
// classic "drag past the viewport edge keeps scrolling" affordance.
|
||||
func TestTranscriptSelectionDragPastTopEdgeAutoScrolls(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.mouseCapture = true
|
||||
// This test exercises the animated glide specifically; reducedMotion
|
||||
// defaults from TTY detection (see defaultReducedMotion), which differs
|
||||
// between a local dev shell and CI's non-TTY runners — set it explicitly
|
||||
// so the test is deterministic regardless of environment.
|
||||
m.reducedMotion = false
|
||||
for i := 0; i < 80; i++ {
|
||||
m.transcript = appendRow(m.transcript, rowUser, "line content")
|
||||
}
|
||||
textY := topmostVisibleTranscriptMouseY(t, m)
|
||||
|
||||
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 0, textY))
|
||||
m = updated.(model)
|
||||
if !m.transcriptSelection.active {
|
||||
t.Fatal("selection should be active after a left click on transcript text")
|
||||
}
|
||||
cursorBefore := m.transcriptSelection.cursor.bodyY
|
||||
scrollBefore := m.chatScrollOffset
|
||||
|
||||
frame, _, _ := m.transcriptHitTestLayout()
|
||||
aboveBody := frame.bodyRect.y - 1 // one row above the visible transcript body
|
||||
|
||||
// A left-button drag (not a hover) moved past the top edge: the smooth-glide
|
||||
// chain must start immediately (first step + a scheduled tick).
|
||||
updated, cmd := m.Update(testMouseMotion(tea.MouseLeft, 0, aboveBody))
|
||||
m = updated.(model)
|
||||
if cmd == nil {
|
||||
t.Fatal("crossing the top edge should schedule the glide tick chain")
|
||||
}
|
||||
if m.edgeScrollDelta <= 0 {
|
||||
t.Fatalf("edgeScrollDelta = %d, want > 0 (scrolling toward older content)", m.edgeScrollDelta)
|
||||
}
|
||||
if m.chatScrollOffset == scrollBefore {
|
||||
t.Fatal("dragging past the top edge should auto-scroll toward older content")
|
||||
}
|
||||
|
||||
// A single small step may land on a blank spacer row between messages (no
|
||||
// text there yet) — drive the tick chain forward like a sustained real hold
|
||||
// would, and the cursor must eventually reach a genuinely earlier line.
|
||||
for i := 0; i < 10 && m.transcriptSelection.cursor.bodyY >= cursorBefore; i++ {
|
||||
updated, _ = m.Update(dragEdgeScrollTickMsg{seq: m.edgeScrollSeq})
|
||||
m = updated.(model)
|
||||
}
|
||||
|
||||
if !m.transcriptSelection.active {
|
||||
t.Fatal("selection must survive an edge auto-scroll, not be cleared")
|
||||
}
|
||||
if m.transcriptSelection.cursor.bodyY >= cursorBefore {
|
||||
t.Fatalf("selection cursor bodyY = %d, want < %d (it must extend upward to follow the drag)", m.transcriptSelection.cursor.bodyY, cursorBefore)
|
||||
}
|
||||
}
|
||||
|
||||
// Symmetric to the top-edge case: dragging past the BOTTOM edge scrolls toward
|
||||
// newer content. Requires scrolling up first so there's somewhere to scroll back
|
||||
// down to (chatScrollOffset=0 already sits at the bottom).
|
||||
func TestTranscriptSelectionDragPastBottomEdgeAutoScrolls(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.mouseCapture = true
|
||||
m.reducedMotion = false // deterministic across local/CI TTY-detection differences
|
||||
for i := 0; i < 80; i++ {
|
||||
m.transcript = appendRow(m.transcript, rowUser, "line content")
|
||||
}
|
||||
m.chatScrollOffset = chatWheelScrollLines * 3 // scrolled up, away from the bottom
|
||||
|
||||
textY := topmostVisibleTranscriptMouseY(t, m)
|
||||
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 0, textY))
|
||||
m = updated.(model)
|
||||
if !m.transcriptSelection.active {
|
||||
t.Fatal("selection should be active after a left click on transcript text")
|
||||
}
|
||||
cursorBefore := m.transcriptSelection.cursor.bodyY
|
||||
scrollBefore := m.chatScrollOffset
|
||||
|
||||
frame, _, _ := m.transcriptHitTestLayout()
|
||||
belowBody := frame.bodyRect.y + frame.bodyRect.height // one row below the visible transcript body
|
||||
|
||||
updated, cmd := m.Update(testMouseMotion(tea.MouseLeft, 0, belowBody))
|
||||
m = updated.(model)
|
||||
if cmd == nil {
|
||||
t.Fatal("crossing the bottom edge should schedule the glide tick chain")
|
||||
}
|
||||
if m.edgeScrollDelta >= 0 {
|
||||
t.Fatalf("edgeScrollDelta = %d, want < 0 (scrolling toward newer content)", m.edgeScrollDelta)
|
||||
}
|
||||
if m.chatScrollOffset >= scrollBefore {
|
||||
t.Fatal("dragging past the bottom edge should auto-scroll toward newer content (offset decreases)")
|
||||
}
|
||||
|
||||
for i := 0; i < 10 && m.transcriptSelection.cursor.bodyY <= cursorBefore; i++ {
|
||||
updated, _ = m.Update(dragEdgeScrollTickMsg{seq: m.edgeScrollSeq})
|
||||
m = updated.(model)
|
||||
}
|
||||
|
||||
if !m.transcriptSelection.active {
|
||||
t.Fatal("selection must survive an edge auto-scroll, not be cleared")
|
||||
}
|
||||
if m.transcriptSelection.cursor.bodyY <= cursorBefore {
|
||||
t.Fatalf("selection cursor bodyY = %d, want > %d (it must extend downward to follow the drag)", m.transcriptSelection.cursor.bodyY, cursorBefore)
|
||||
}
|
||||
}
|
||||
|
||||
// The glide tick chain must self-terminate the instant the drag moves back into
|
||||
// the visible body — otherwise a stray scheduled tick could keep scrolling after
|
||||
// the user has already dragged back to the content they wanted selected.
|
||||
func TestTranscriptSelectionEdgeGlideStopsWhenDragReturnsToBody(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.mouseCapture = true
|
||||
m.reducedMotion = false // deterministic across local/CI TTY-detection differences
|
||||
for i := 0; i < 80; i++ {
|
||||
m.transcript = appendRow(m.transcript, rowUser, "line content")
|
||||
}
|
||||
textY := topmostVisibleTranscriptMouseY(t, m)
|
||||
|
||||
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 0, textY))
|
||||
m = updated.(model)
|
||||
frame, _, _ := m.transcriptHitTestLayout()
|
||||
updated, _ = m.Update(testMouseMotion(tea.MouseLeft, 0, frame.bodyRect.y-1))
|
||||
m = updated.(model)
|
||||
if m.edgeScrollDelta == 0 {
|
||||
t.Fatal("sanity check failed: the glide chain should be running")
|
||||
}
|
||||
seqWhileGliding := m.edgeScrollSeq
|
||||
|
||||
updated, _ = m.Update(testMouseMotion(tea.MouseLeft, 0, textY))
|
||||
m = updated.(model)
|
||||
if m.edgeScrollDelta != 0 {
|
||||
t.Fatalf("edgeScrollDelta = %d, want 0 (dragging back into the body must stop the glide)", m.edgeScrollDelta)
|
||||
}
|
||||
|
||||
// A tick already in flight from before the drag returned must be a no-op —
|
||||
// it carries the OLD seq, which no longer matches.
|
||||
scrollBefore := m.chatScrollOffset
|
||||
updated, _ = m.Update(dragEdgeScrollTickMsg{seq: seqWhileGliding})
|
||||
m = updated.(model)
|
||||
if m.chatScrollOffset != scrollBefore {
|
||||
t.Fatal("a stale in-flight tick must not scroll after the glide was stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// A keypress mid-glide clears m.transcriptSelection.active directly (the
|
||||
// KeyPressMsg handler in model.go), NOT through stopEdgeScroll — so
|
||||
// edgeScrollDelta is left stale. If a fresh drag past the SAME edge afterward
|
||||
// doesn't reset it, startEdgeScroll's "already running" fast path is fooled into
|
||||
// thinking a chain is already active and silently does nothing: no immediate
|
||||
// step, no scheduled tick. The press handler must reset glide state so every new
|
||||
// drag starts clean regardless of how the previous one ended.
|
||||
func TestTranscriptSelectionEdgeGlideRecoversAfterKeypressInterrupt(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.mouseCapture = true
|
||||
m.reducedMotion = false // deterministic across local/CI TTY-detection differences
|
||||
for i := 0; i < 80; i++ {
|
||||
m.transcript = appendRow(m.transcript, rowUser, "line content")
|
||||
}
|
||||
textY := topmostVisibleTranscriptMouseY(t, m)
|
||||
|
||||
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 0, textY))
|
||||
m = updated.(model)
|
||||
frame, _, _ := m.transcriptHitTestLayout()
|
||||
aboveBody := frame.bodyRect.y - 1
|
||||
|
||||
updated, _ = m.Update(testMouseMotion(tea.MouseLeft, 0, aboveBody))
|
||||
m = updated.(model)
|
||||
if m.edgeScrollDelta == 0 {
|
||||
t.Fatal("sanity check failed: the glide chain should be running")
|
||||
}
|
||||
|
||||
// A keypress interrupts the drag WITHOUT releasing the mouse — clears
|
||||
// transcriptSelection.active directly, bypassing stopEdgeScroll.
|
||||
updated, _ = m.Update(testKey('a'))
|
||||
m = updated.(model)
|
||||
if m.transcriptSelection.active {
|
||||
t.Fatal("sanity check failed: the keypress should have cleared the selection")
|
||||
}
|
||||
if m.edgeScrollDelta == 0 {
|
||||
t.Fatal("sanity check failed: edgeScrollDelta should still be stale here (that's the bug this test guards)")
|
||||
}
|
||||
|
||||
// A brand-new press+drag past the SAME edge must glide normally, not be
|
||||
// silently swallowed by the stale state from the interrupted drag. The
|
||||
// viewport shifted from the first glide's step, so the topmost visible text
|
||||
// line's on-screen Y must be recomputed — reusing the ORIGINAL textY would
|
||||
// fail for an unrelated reason (it may now land on a blank spacer row).
|
||||
textY = topmostVisibleTranscriptMouseY(t, m)
|
||||
updated, _ = m.Update(testMouseClick(tea.MouseLeft, 0, textY))
|
||||
m = updated.(model)
|
||||
if !m.transcriptSelection.active {
|
||||
t.Fatal("sanity check failed: the new press should start a fresh selection")
|
||||
}
|
||||
scrollBefore := m.chatScrollOffset
|
||||
|
||||
updated, cmd := m.Update(testMouseMotion(tea.MouseLeft, 0, aboveBody))
|
||||
m = updated.(model)
|
||||
if cmd == nil {
|
||||
t.Fatal("the new drag should schedule a fresh glide tick chain, not be fooled by stale state")
|
||||
}
|
||||
if m.chatScrollOffset == scrollBefore {
|
||||
t.Fatal("the new drag should have applied its immediate first scroll step")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptSelectionClearsAfterCopy(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.transcriptSelection = transcriptSelectionState{active: true}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
@@ -607,6 +608,15 @@ func (m model) renderContextSidebar(width, height int) []string {
|
||||
}
|
||||
add(tokenLine)
|
||||
|
||||
// Hover highlight: resolved by STABLE IDENTITY (sessionID / stepIndex), not a
|
||||
// cached line offset — see hoveredSidebarLineOffset. A row whose identity no
|
||||
// longer resolves (it disappeared since the hover was last set from a real
|
||||
// mouse motion) simply doesn't highlight, rather than a coincidentally-matching
|
||||
// unrelated row lighting up.
|
||||
if lineOffset, ok := m.hoveredSidebarLineOffset(width); ok && lineOffset >= 0 && lineOffset < len(lines) {
|
||||
lines[lineOffset] = zeroTheme.hover.Render(ansi.Strip(lines[lineOffset]))
|
||||
}
|
||||
|
||||
// Normalize every row to exactly width cells.
|
||||
for i := range lines {
|
||||
lines[i] = padStyledLine(lines[i], width)
|
||||
@@ -620,6 +630,31 @@ func (m model) renderContextSidebar(width, height int) []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
// hoveredSidebarLineOffset resolves the hovered sidebar row's CURRENT line offset
|
||||
// fresh on every call, by re-matching m.hover's stable identity (sessionID for an
|
||||
// agent, stepIndex for a plan step) against a freshly computed hit list — never a
|
||||
// cached index. Returns false when the hover isn't sidebar-scoped, or when the
|
||||
// identity no longer resolves (that row disappeared since the hover was last set
|
||||
// by a real mouse motion — a linger window elapsing, a plan step completing —
|
||||
// with no intervening motion to re-target it).
|
||||
func (m model) hoveredSidebarLineOffset(width int) (int, bool) {
|
||||
switch m.hover.kind {
|
||||
case hoverSidebarAgent:
|
||||
for _, hit := range m.sidebarAgentSelectables(width) {
|
||||
if hit.sessionID == m.hover.sessionID {
|
||||
return hit.lineOffset, true
|
||||
}
|
||||
}
|
||||
case hoverPlanStep:
|
||||
for _, hit := range m.sidebarPlanSelectables(width) {
|
||||
if hit.stepIndex == m.hover.stepIndex {
|
||||
return hit.lineOffset, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// sidebarHeader renders a bold-muted uppercase section label. Bold muted (vs the
|
||||
// faint body items and placeholders) gives the header enough weight to read as a
|
||||
// section heading rather than more filler. The width arg is unused — kept so it
|
||||
|
||||
@@ -32,9 +32,9 @@ func TestStreamingFadePaletteMonotonic(t *testing.T) {
|
||||
// so per-bucket comparisons are byte-stable. The endpoint byte
|
||||
// values are the contract — the hex strings are not.
|
||||
fresh := rgbaOf(t, palette[0])
|
||||
// Sanity: palette[0] must equal the accent endpoint (#caff3f).
|
||||
if fresh.R != 0xca || fresh.G != 0xff || fresh.B != 0x3f {
|
||||
t.Errorf("palette[0] RGBA = %v, want {R:0xca, G:0xff, B:0x3f} (accent)", fresh)
|
||||
// Sanity: palette[0] must equal the accent endpoint (#759523).
|
||||
if fresh.R != 0x75 || fresh.G != 0x95 || fresh.B != 0x23 {
|
||||
t.Errorf("palette[0] RGBA = %v, want {R:0x75, G:0x95, B:0x23} (accent)", fresh)
|
||||
}
|
||||
last := rgbaOf(t, palette[streamingFadeSteps-1])
|
||||
if last == fresh {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// The subchat drill-in (viewing a subagent/swarm child session) swaps the on-screen
|
||||
// transcript to m.subchat.childRows, but mouse hit-testing and the footer's pinned
|
||||
// plan panel were never made subchat-aware — they kept operating on/showing the
|
||||
// PARENT run's state while the child session was on screen. These tests guard the
|
||||
// fix: mouse selection must resolve against the child rows, the plan panel must not
|
||||
// show in that view, and a selection in progress must extend across a wheel-scroll
|
||||
// instead of freezing at whatever was visible when the drag started.
|
||||
|
||||
func TestFooterHidesPlanPanelDuringSubchat(t *testing.T) {
|
||||
m := runningPlanModel(t, 3)
|
||||
m.altScreen = true
|
||||
m.height = 30
|
||||
width := m.chatColumnWidth()
|
||||
|
||||
withoutSubchat := m.footerView(width)
|
||||
if !strings.Contains(withoutSubchat, "Step number 1 here") {
|
||||
t.Fatalf("sanity check failed: pinned plan panel should render outside subchat, got:\n%s", withoutSubchat)
|
||||
}
|
||||
|
||||
m.subchat.active = true
|
||||
m.subchat.childSessionID = "child-1"
|
||||
withSubchat := m.footerView(width)
|
||||
if strings.Contains(withSubchat, "Step number 1 here") {
|
||||
t.Fatalf("plan panel must NOT show while viewing a subchat child session, got:\n%s", withSubchat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptSelectionInSubchatUsesChildSessionRows(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.mouseCapture = true
|
||||
// Parent transcript has DIFFERENT text than the child session: if selection
|
||||
// hit-tests against the wrong (parent) rows, this proves it by either matching
|
||||
// nothing or matching the wrong text.
|
||||
m.transcript = appendRow(m.transcript, rowUser, "parent transcript text")
|
||||
m.subchat.active = true
|
||||
m.subchat.childSessionID = "child-1"
|
||||
m.subchat.childRows = appendRow(nil, rowUser, "hello world")
|
||||
|
||||
textY := topmostVisibleTranscriptMouseY(t, m)
|
||||
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 3, textY))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(testMouseMotion(tea.MouseLeft, 8, textY))
|
||||
m = updated.(model)
|
||||
|
||||
if got := m.selectedTranscriptText(); got != "hello" {
|
||||
t.Fatalf("selectedTranscriptText() in subchat = %q, want hello (from the CHILD session's rows)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptSelectionExtendsAcrossWheelScroll(t *testing.T) {
|
||||
m := mouseTestModel()
|
||||
m.mouseCapture = true
|
||||
// Enough rows that the transcript overflows the viewport, so a wheel-scroll is
|
||||
// a real (non-clamped) scroll. chatScrollOffset=0 anchors to the BOTTOM (newest
|
||||
// content, see transcriptViewport.window: start = totalLines-height-offset), so
|
||||
// with overflow content the topmost VISIBLE line is not the transcript's first
|
||||
// line — topmostVisibleTranscriptMouseY (window-aware) finds it correctly.
|
||||
for i := 0; i < 80; i++ {
|
||||
m.transcript = appendRow(m.transcript, rowUser, "line content")
|
||||
}
|
||||
textY := topmostVisibleTranscriptMouseY(t, m)
|
||||
|
||||
updated, _ := m.Update(testMouseClick(tea.MouseLeft, 0, textY))
|
||||
m = updated.(model)
|
||||
if !m.transcriptSelection.active {
|
||||
t.Fatal("selection should be active after a left click on transcript text")
|
||||
}
|
||||
cursorBefore := m.transcriptSelection.cursor.bodyY
|
||||
scrollBefore := m.chatScrollOffset
|
||||
|
||||
// Wheel UP reveals OLDER content above (offset increases -> window.start
|
||||
// decreases): re-evaluating the SAME on-screen Y afterward must land on an
|
||||
// EARLIER (smaller bodyY) line now that the viewport has shifted, extending the
|
||||
// selection upward instead of leaving the cursor pinned to the pre-scroll line.
|
||||
updated, _ = m.Update(testMouseWheel(tea.MouseWheelUp, 0, textY))
|
||||
m = updated.(model)
|
||||
|
||||
if m.chatScrollOffset == scrollBefore {
|
||||
t.Fatal("sanity check failed: wheel-up should have scrolled (80 rows overflow a 30-row terminal)")
|
||||
}
|
||||
if !m.transcriptSelection.active {
|
||||
t.Fatal("selection must survive a wheel-scroll, not be cleared")
|
||||
}
|
||||
if m.transcriptSelection.cursor.bodyY >= cursorBefore {
|
||||
t.Fatalf("selection cursor bodyY = %d, want < %d (it must extend to follow the scroll, not freeze)", m.transcriptSelection.cursor.bodyY, cursorBefore)
|
||||
}
|
||||
}
|
||||
|
||||
// topmostVisibleTranscriptMouseY returns the on-screen Y of the topmost currently
|
||||
// VISIBLE selectable text line — window-aware (unlike firstTranscriptTextMouseY,
|
||||
// which walks the full unwindowed layout and only lands on-screen when everything
|
||||
// fits in one viewport). It resolves against transcriptHitTestSource, the same
|
||||
// subchat-aware source transcriptLineAtMouse uses, so it works for both the parent
|
||||
// transcript and a subchat child session.
|
||||
func topmostVisibleTranscriptMouseY(t *testing.T, m model) int {
|
||||
t.Helper()
|
||||
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)
|
||||
for _, line := range layout.selectable {
|
||||
if line.text != "" && !line.toggle {
|
||||
return frame.bodyRect.y + (line.bodyY - window.start)
|
||||
}
|
||||
}
|
||||
t.Fatalf("no selectable visible transcript text line found: %#v", layout.selectable)
|
||||
return 0
|
||||
}
|
||||
+25
-9
@@ -29,6 +29,7 @@ type tuiTheme struct {
|
||||
line lipgloss.Style // default borders, rules, status separators
|
||||
lineStrong lipgloss.Style // emphasized borders
|
||||
selection lipgloss.Style // transcript selection highlight
|
||||
hover lipgloss.Style // hovered clickable row (specialist card, toggle, sidebar/plan row)
|
||||
|
||||
// Title bar.
|
||||
badge lipgloss.Style // ` 0 ` brand chip: onAccent on accent, bold
|
||||
@@ -128,15 +129,23 @@ type palette struct {
|
||||
// lime accent. bg (#070708) is the terminal's own canvas — deliberately never
|
||||
// painted — so no token references it.
|
||||
var darkPalette = palette{
|
||||
panel: "#0e0e10",
|
||||
promptBg: "#262626",
|
||||
line: "#242429",
|
||||
line2: "#414147",
|
||||
ink: "#ececee",
|
||||
muted: "#9a9aa2", // secondary text — lifted so it clearly out-ranks faint
|
||||
faint: "#8a8a92", // hints/metadata — nudged up to separate from faintest
|
||||
faintest: "#7c7c82", // line numbers/separators — pinned at the WCAG-AA floor on the dark panel
|
||||
accent: "#caff3f",
|
||||
panel: "#0e0e10",
|
||||
promptBg: "#262626",
|
||||
line: "#242429",
|
||||
line2: "#414147",
|
||||
ink: "#ececee",
|
||||
muted: "#9a9aa2", // secondary text — lifted so it clearly out-ranks faint
|
||||
faint: "#8a8a92", // hints/metadata — nudged up to separate from faintest
|
||||
faintest: "#7c7c82", // line numbers/separators — pinned at the WCAG-AA floor on the dark panel
|
||||
// Brand lime, softened from the original #caff3f (L 62%, S 100% — fully
|
||||
// saturated, which read as glaring/neon everywhere it filled a block or
|
||||
// bold-text surface: the composer prompt glyph, the permission popup's
|
||||
// focused-option badge, the text-selection highlight). Two prior passes
|
||||
// (#ade619 at L50/S80, #8db620 at L42/S70) were each confirmed too subtle
|
||||
// in turn; settled at L 36%/S 62% — a darker, more olive lime. Contrast
|
||||
// vs both black (onAccent) and the near-black panel stays comfortably
|
||||
// above WCAG AA (~6.1:1 and ~5.6:1) with real margin to spare.
|
||||
accent: "#759523",
|
||||
green: "#5dd1a4",
|
||||
red: "#ff7a7a",
|
||||
amber: "#ffc25c",
|
||||
@@ -210,6 +219,13 @@ func buildTheme(p palette) tuiTheme {
|
||||
line: fg(p.line),
|
||||
lineStrong: fg(p.line2),
|
||||
selection: lipgloss.NewStyle().Background(col(p.accent)).Foreground(col(p.onAccent)),
|
||||
// A full block (like selection) would be too heavy for something that
|
||||
// repaints on every mouse movement; a soft foreground reads as
|
||||
// "interactive" at a glance without competing with real content colors
|
||||
// (e.g. a specialist card's red/green status glyph). Amber (not the brand
|
||||
// lime accent) — lime read as too glaring for something that repaints
|
||||
// continuously as the cursor moves.
|
||||
hover: fg(p.amber),
|
||||
|
||||
badge: lipgloss.NewStyle().Background(col(p.accent)).Foreground(col(p.onAccent)).Bold(true),
|
||||
|
||||
|
||||
@@ -22,8 +22,20 @@ type transcriptSelectionPoint struct {
|
||||
|
||||
type transcriptSelectionState struct {
|
||||
active bool
|
||||
anchor transcriptSelectionPoint
|
||||
cursor transcriptSelectionPoint
|
||||
// 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 {
|
||||
@@ -159,10 +171,45 @@ func (m model) finalizeTranscriptBodyRow(rendered string, selectable []transcrip
|
||||
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
|
||||
@@ -968,16 +1015,46 @@ func splitPlainAtDisplayWidth(text string, width int) (string, string) {
|
||||
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.altScreen || m.height <= 0 || m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
|
||||
if m.transcriptHitTestBlocked() {
|
||||
return transcriptSelectableLine{}, false
|
||||
}
|
||||
width := m.chatColumnWidth()
|
||||
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
|
||||
items := m.transcriptBodyItems(width, "")
|
||||
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
||||
window := transcriptViewportForLayout(metrics, frame, m.chatScrollOffset).window()
|
||||
layout := layoutVisibleTranscriptBodyItems(items, metrics, window)
|
||||
frame, window, layout := m.transcriptHitTestLayout()
|
||||
_, localY, ok := frame.bodyRect.local(mouseX(msg), mouseY(msg))
|
||||
if !ok {
|
||||
return transcriptSelectableLine{}, false
|
||||
@@ -995,6 +1072,146 @@ func (m model) transcriptLineAtMouse(msg tea.MouseMsg) (transcriptSelectableLine
|
||||
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)
|
||||
@@ -1029,6 +1246,7 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
|
||||
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
|
||||
@@ -1058,6 +1276,7 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
|
||||
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 {
|
||||
@@ -1070,21 +1289,57 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
|
||||
}
|
||||
point := transcriptSelectionPointForMouse(line, mouseX(msg))
|
||||
m.copyStatus = ""
|
||||
m.transcriptSelection = transcriptSelectionState{active: true, anchor: point, cursor: point}
|
||||
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):
|
||||
if !m.transcriptSelection.active {
|
||||
// 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
|
||||
}
|
||||
line, ok := m.transcriptLineAtMouse(msg)
|
||||
if ok {
|
||||
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
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -1093,6 +1348,10 @@ func (m model) handleTranscriptSelectionMouse(msg tea.MouseMsg) (model, tea.Cmd,
|
||||
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
|
||||
@@ -1113,8 +1372,11 @@ func (m model) toggleTranscriptRow(rowIndex int) model {
|
||||
}
|
||||
|
||||
func (m model) selectedTranscriptText() string {
|
||||
width := m.chatColumnWidth()
|
||||
layout := m.transcriptBodyLayout(width, "")
|
||||
// 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)
|
||||
|
||||
Reference in new issue
Block a user