feat(tui-selection): drag-select highlight + OSC 52 auto-copy (#2532, presentation wave Part 3, red-first)

A left-press inside a text pane (chat/tasks) starts a drag; motion
with the button held extends the head clamped to the pane's rect; the
release extracts exactly the highlighted WORDS (ANSI-stripped,
newlines between rows) and copies via OSC 52 — DIRECT escape emission
(belt-terminated, base64 payload; the cell parser drops OSC so the
sequence can never ride the view), capability-detected on the OSC 8
denylist law (known non-carriers + RUNE_NO_OSC52 degrade to
highlight-only, honestly). The highlight is a shell-owned display
overlay (reverse video at View time, display-column faithful); chrome
that owns the mouse (picker, palette, cards) never starts a drag; a
collapsed drag (plain click) copies nothing. All-motion mouse enabled
for the drag leg.
This commit is contained in:
Nick committed 2026-10-22 17:35:05 +01:00
1 parent 54bc75dcb3
commit 1ea093ad18
4 files changed
+484 -16

No files matched your search

+13 -13
View File
@@ -4,19 +4,19 @@
// answer (TUI-side, after the turn — zero model-side cost, B1) and
// each block type takes its presentation (B2):
//
// 1. Speech (prose) — a bubble that GROWS with content; consecutive
// prose joins ONE bubble; left-aligned; the outline carries the
// SPEAKER'S colour (owner ruling #5).
// 2. Table — BREAKS OUT of the bubble: a full-pane-width bordered
// table, header emphasised, border in the speaker's colour.
// 3. Bullet lists / numbered steps — own presentation OUTSIDE the
// bubble: a left rail in the speaker's colour, hanging indent,
// steps numbered, genuine bullets dotted. NEVER per-sentence
// bullets (the D47 defect).
// 4. Code fences — route to the tasks pane work stream (D47/D44
// law, already contracted; the chat keeps the D36 collapsed
// summary via the rich-bubble path).
// 5. Headings — full-width, speaker-coloured underline, no bubble.
// 1. Speech (prose) — a bubble that GROWS with content; consecutive
// prose joins ONE bubble; left-aligned; the outline carries the
// SPEAKER'S colour (owner ruling #5).
// 2. Table — BREAKS OUT of the bubble: a full-pane-width bordered
// table, header emphasised, border in the speaker's colour.
// 3. Bullet lists / numbered steps — own presentation OUTSIDE the
// bubble: a left rail in the speaker's colour, hanging indent,
// steps numbered, genuine bullets dotted. NEVER per-sentence
// bullets (the D47 defect).
// 4. Code fences — route to the tasks pane work stream (D47/D44
// law, already contracted; the chat keeps the D36 collapsed
// summary via the rich-bubble path).
// 5. Headings — full-width, speaker-coloured underline, no bubble.
//
// B4: a long answer never breaks the bubble — the growing-bubble
// model replaces the fixed-shape card; answers stay left-aligned.
+39 -3
View File
@@ -276,6 +276,11 @@ type model struct {
consentHub *consent.Hub
consentCard *consent.Card
consentBusy bool
// THE SELECTION LAYER (#2532): the drag-selection's display state
// (selection.go) — anchor/head/pane. Shell-owned display state,
// never face state.
sel selState
}
func initialModel() model {
@@ -2434,6 +2439,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.applyApprovalReceipt(msg)
return m, nil
case tea.MouseClickMsg:
// THE SELECTION LAYER (#2532): a left press inside a text
// pane starts a drag selection (the release copies via OSC 52
// when the drag moved); chrome that owns the mouse (picker,
// palette, cards) never starts one. The face's own click
// routing still runs — a press that never moves is a click.
if msg.Button == tea.MouseLeft && !m.chromeOwnsMouse() {
m.selStart(msg.X, msg.Y, m.face.Render().Regions)
}
// the T5 card's mouse leg: the y/n buttons' regions resolve
// FIRST (the card is a decision boundary), then the face
// chain's own regions — the shell adds no other click
@@ -2491,6 +2504,24 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.face.Click(msg.X, msg.Y)
m.syncFocusSignage(focusBefore)
return m, nil
case tea.MouseMotionMsg:
// the #2532 drag-select's motion leg: while the left button
// holds and the drag started in a text pane, the head follows
// the pointer (clamped to the pane). Motion alone never routes
// the face.
if msg.Button == tea.MouseLeft && m.sel.inFlight() {
m.selMove(msg.X, msg.Y, m.face.Render().Regions)
}
return m, nil
case tea.MouseReleaseMsg:
// the #2532 drag-select's release leg: the highlight's text
// copies via OSC 52 (capability-gated, direct emission) and
// the highlight clears. A collapsed drag (a plain click)
// copies nothing.
if msg.Button == tea.MouseLeft && m.sel.inFlight() {
m.selFinish(m.face.Render().Content, os.Stdout)
}
return m, nil
}
return m, nil
}
@@ -2504,6 +2535,9 @@ func (m model) View() tea.View {
if m.face.ActivityWindowOpen() {
content = m.overlayActivityWindow(content)
}
// the #2532 drag highlight overlays the frame while the drag is
// in flight (selection.go — shell display state, never a pane).
content = m.overlaySelection(content)
var b strings.Builder
b.WriteString(content)
b.WriteString("\n")
@@ -2520,10 +2554,11 @@ func (m model) View() tea.View {
// must DISAPPEAR — the terminal restores the pre-boot command line,
// never a ghost of the last frame). Alt-screen draws the face on
// the terminal's separate screen buffer; quitting restores the
// main screen exactly as it was. Mouse cell-motion stays on for
// the selection law's click leg (D29/TD-4).
// main screen exactly as it was. ALL-motion mouse stays on: the
// selection law's click leg (D29/TD-4) and the #2532 drag-select
// (motion events while the button holds).
v.AltScreen = true
v.MouseMode = tea.MouseModeCellMotion
v.MouseMode = tea.MouseModeAllMotion
return v
}
@@ -3272,6 +3307,7 @@ func main() {
return
}
wireOSCDetection() // the version hyperlink's real-terminal detection
wireOSC52Detection() // the selection copy's real-terminal detection (#2532)
bootCheck = update.DefaultCheck
// D38: the ledger section reads the task markdown ledger through
// the library seam — the same directory the serve surface
+234
View File
@@ -0,0 +1,234 @@
// selection.go — THE SELECTION + AUTO-COPY layer (#2532, the
// presentation wave Part 3): mouse-drag highlights text per pane and
// the release copies it via OSC 52 — capability-detected, DIRECT
// escape emission (the same law as the OSC 8 denylist gate: a known
// non-carrier degrades honestly, the selection still highlights, the
// copy never fabricates).
//
// Layer law: this is the SHELL's own display state — the drag's
// anchor/head, the pane it started in, the last copy. The face chain
// never knows; the highlight is an overlay applied to the rendered
// frame at View time, never a pane concern.
package main
import (
"encoding/base64"
"io"
"os"
"strings"
"rune.dev/rune/tui/semantic"
)
// selState is one drag-selection's display state.
type selState struct {
active bool
anchorX, head int // head X
anchorY, headY int
pane string // the starting region's ID ("region.chat" | "region.tasks")
}
// selInFlight reports whether a drag is highlighting right now.
func (s *selState) inFlight() bool { return s.active }
// osc52Allowed gates the OSC 52 copy (detect-then-emit): known
// non-carriers and the explicit opt-out degrade to highlight-only.
// Hermetic tests stay false by construction (the default); the real
// main() wires the detection in — exactly the linkVersions law.
var osc52Allowed = func() bool { return false }
// osc52Emit writes the OSC 52 copy sequence for text — DIRECT escape
// emission: the sequence must reach the terminal byte-intact, so it
// never rides the view (the cell parser drops OSC). Belt-terminated
// (\x07): the most widely honored terminator.
func osc52Emit(out io.Writer, text string) {
if !osc52Allowed() || text == "" {
return
}
enc := base64.StdEncoding.EncodeToString([]byte(text))
_, _ = io.WriteString(out, "\x1b]52;c;"+enc+"\x07")
}
// selStart begins a drag at (x,y) when the cell sits inside a
// selectable text pane (the chat or tasks region) and no chrome
// surface owns the mouse; it reports whether a selection started.
func (m *model) selStart(x, y int, regions []semantic.Region) bool {
m.sel = selState{}
if m.chromeOwnsMouse() {
return false
}
for _, r := range regions {
if r.ID != "region.chat" && r.ID != "region.tasks" {
continue
}
if x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H {
m.sel = selState{active: true, pane: r.ID,
anchorX: x, head: x, anchorY: y, headY: y}
return true
}
}
return false
}
// selMove extends the drag's head to (x,y), clamped to the starting
// pane's rectangle — a selection never bleeds across panes.
func (m *model) selMove(x, y int, regions []semantic.Region) {
if !m.sel.active {
return
}
for _, r := range regions {
if r.ID != m.sel.pane {
continue
}
m.sel.head = min(max(x, r.X), r.X+r.W-1)
m.sel.headY = min(max(y, r.Y), r.Y+r.H-1)
return
}
}
// selFinish ends the drag, extracts the highlighted text from the
// frame's content, copies it via OSC 52, and clears the highlight.
// Returns the copied text ("" when the drag collapsed to nothing).
func (m *model) selFinish(content string, out io.Writer) string {
if !m.sel.active {
return ""
}
text := m.selText(content)
m.sel = selState{}
if text != "" {
osc52Emit(out, text)
}
return text
}
// selText extracts the selected text: the display columns of the
// selected rows, ANSI-stripped (the copy is the WORDS the operator
// saw), rows joined with newlines. A single-row drag takes its
// column span; a multi-row drag takes the anchor's row tail, the
// whole middle rows, and the head row's prefix.
func (m *model) selText(content string) string {
lines := strings.Split(content, "\n")
y0, y1 := m.sel.anchorY, m.sel.headY
x0, x1 := m.sel.anchorX, m.sel.head
if y0 > y1 || (y0 == y1 && x0 > x1) {
y0, y1 = y1, y0
x0, x1 = x1, x0
}
var rows []string
for y := y0; y <= y1 && y < len(lines); y++ {
r := []rune(semantic.StripANSI(lines[y]))
lo, hi := 0, len(r)
if y == y0 && y0 != y1 {
lo = min(x0, len(r))
} else if y == y0 && y0 == y1 {
lo, hi = min(x0, len(r)), min(x1+1, len(r))
} else if y == y1 {
hi = min(x1+1, len(r))
}
if lo > hi {
lo = hi
}
rows = append(rows, strings.TrimRight(string(r[lo:hi]), " "))
}
return strings.Join(rows, "\n")
}
// overlaySelection paints the drag's highlight over the frame
// content: the selected cells render in reverse video — an overlay,
// never a pane concern. Applied only while the drag is in flight.
func (m *model) overlaySelection(content string) string {
if !m.sel.active {
return content
}
lines := strings.Split(content, "\n")
y0, y1 := m.sel.anchorY, m.sel.headY
x0, x1 := m.sel.anchorX, m.sel.head
if y0 > y1 || (y0 == y1 && x0 > x1) {
y0, y1 = y1, y0
x0, x1 = x1, x0
}
for y := y0; y <= y1 && y < len(lines); y++ {
lo, hi := 0, widthANSIOf(lines[y])
if y == y0 && y0 != y1 {
lo = min(x0, hi)
} else if y == y0 && y0 == y1 {
lo, hi = min(x0, hi), min(x1+1, hi)
} else if y == y1 {
hi = min(x1+1, hi)
}
if lo >= hi {
continue
}
lines[y] = spliceReverse(lines[y], lo, hi)
}
return strings.Join(lines, "\n")
}
// spliceReverse wraps the display columns [lo,hi) of a styled line in
// reverse video, keeping every ANSI run intact on both sides.
func spliceReverse(line string, lo, hi int) string {
var b strings.Builder
col := 0
inEsc := false
var esc strings.Builder
for _, r := range line {
if r == '\x1b' {
inEsc = true
esc.Reset()
esc.WriteRune(r)
continue
}
if inEsc {
esc.WriteRune(r)
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
b.WriteString(esc.String())
inEsc = false
}
continue
}
if col == lo {
b.WriteString("\x1b[7m")
}
b.WriteRune(r)
col++
if col == hi {
b.WriteString("\x1b[27m")
}
}
return b.String()
}
// widthANSIOf measures one line's display columns (escapes carry none).
func widthANSIOf(s string) int { return len([]rune(semantic.StripANSI(s))) }
// chromeOwnsMouse reports whether a chrome surface (the centered
// picker, the palette, the model menu, a decision card) owns the
// mouse right now — a drag selection never starts under them.
func (m *model) chromeOwnsMouse() bool {
return m.pickerOpen || m.paletteOpen() || m.menuOpen ||
m.apprv != nil || m.consentCard != nil || m.gaugesOpen
}
// wireOSC52Detection installs the real terminal detection for the
// selection copy (#2532, detect-then-emit — the OSC 8 denylist law
// carried): OSC 52 is near-universal on modern terminals (Konsole,
// VTE, kitty, alacritty, foot, WezTerm, iTerm, tmux all honor it,
// some behind an allow-list setting), so the gate DENYLISTS the known
// non-carriers and copies everywhere else; a non-carrier degrades to
// highlight-only (the drag still shows; the copy never fabricates).
// RUNE_NO_OSC52 opts out explicitly.
func wireOSC52Detection() {
osc52Allowed = func() bool {
if os.Getenv("RUNE_NO_OSC52") != "" {
return false
}
switch t := os.Getenv("TERM"); t {
case "dumb", "linux", "vt100", "vt220", "xterm-mono", "cons25", "emacs":
return false
}
if os.Getenv("INSIDE_EMACS") != "" || os.Getenv("EMACS") != "" {
return false
}
return true
}
}
+198
View File
@@ -0,0 +1,198 @@
// selection_test.go — THE SELECTION + AUTO-COPY suite (#2532, the
// presentation wave Part 3): a left-press inside a text pane starts a
// drag; motion extends the head clamped to the pane; the release
// extracts the highlighted WORDS (ANSI-stripped) and copies via OSC 52
// (capability-gated, direct escape emission, base64 payload); a plain
// click copies nothing; chrome never starts a drag.
package main
import (
"os"
"strings"
"testing"
"charm.land/bubbletea/v2"
"rune.dev/rune/tui/chatpane"
"rune.dev/rune/tui/semantic"
)
func selModel(t *testing.T) model {
t.Helper()
m := sized(t, initialModel(), 80, 24)
m.face.AppendChat(chatpane.Message{Role: chatpane.RoleUser,
Content: "the quick brown fox", Ts: "2026-10-22T10:00:00Z"})
return m
}
func chatRegionOf(t *testing.T, m model) semantic.Region {
t.Helper()
for _, r := range m.face.Render().Regions {
if r.ID == "region.chat" {
return r
}
}
t.Fatal("no region.chat")
return semantic.Region{}
}
func TestDragStartsInTextPaneOnly(t *testing.T) {
m := selModel(t)
reg := chatRegionOf(t, m)
if !m.selStart(reg.X+2, reg.Y+3, m.face.Render().Regions) {
t.Fatal("a press inside the chat pane must start a selection")
}
if !m.sel.inFlight() || m.sel.pane != "region.chat" {
t.Fatalf("selection state wrong: %+v", m.sel)
}
// a press outside both text panes starts nothing
if m.selStart(reg.X+reg.W+50, reg.Y, m.face.Render().Regions) {
t.Fatal("a press outside the text panes must not select")
}
}
func TestDragMotionClampsToPane(t *testing.T) {
m := selModel(t)
reg := chatRegionOf(t, m)
m.selStart(reg.X, reg.Y+1, m.face.Render().Regions)
m.selMove(reg.X+reg.W+30, reg.Y-8, m.face.Render().Regions)
if m.sel.head != reg.X+reg.W-1 || m.sel.headY != reg.Y {
t.Fatalf("the drag head must clamp to the pane rect: %+v (pane %+v)", m.sel, reg)
}
}
func TestReleaseExtractsHighlightedWordsAndCopies(t *testing.T) {
allowed := false
osc52Allowed = func() bool { return allowed }
defer func() { osc52Allowed = func() bool { return false } }()
m := selModel(t)
_ = chatRegionOf(t, m)
frame := m.face.Render().Content
// find the row carrying the phrase and the phrase's exact column
rows := strings.Split(semantic.StripANSI(frame), "\n")
row, col := -1, -1
for i, r := range rows {
if j := strings.Index(r, "the quick brown fox"); j >= 0 {
row, col = i, len([]rune(r[:j]))
break
}
}
if row < 0 {
t.Fatalf("the phrase must render:\n%s", frame)
}
m.selStart(col, row, m.face.Render().Regions)
m.selMove(col+len("the quick brown")-1, row, m.face.Render().Regions)
var sb strings.Builder
text := m.selFinish(frame, &fakeOut{&sb})
if text != "the quick brown" {
t.Fatalf("the release must extract exactly the highlighted words, got %q", text)
}
if m.sel.inFlight() {
t.Fatal("the release must clear the highlight")
}
// the OSC 52 emission (gated): the exact sequence, base64 payload
allowed = true
m2 := selModel(t)
m2.selStart(col, row, m2.face.Render().Regions)
m2.selMove(col+len("the quick")-1, row, m2.face.Render().Regions)
sb.Reset()
m2.selFinish(m2.face.Render().Content, &fakeOut{&sb})
out := sb.String()
if !strings.HasPrefix(out, "\x1b]52;c;") || !strings.HasSuffix(out, "\x07") {
t.Fatalf("the copy must be a direct OSC 52 emission, got %q", out)
}
if want := "dGhlIHF1aWNr"; !strings.Contains(out, want) { // base64("the quick")
t.Fatalf("the OSC 52 payload must carry base64(\"the quick\")=dGhlIHF1aWNr, got %q", out)
}
// gate off: no bytes
allowed = false
m3 := selModel(t)
m3.selStart(col, row, m3.face.Render().Regions)
m3.selMove(col+len("the quick")-1, row, m3.face.Render().Regions)
sb.Reset()
m3.selFinish(m3.face.Render().Content, &fakeOut{&sb})
if sb.String() != "" {
t.Fatalf("the gate off must emit nothing, got %q", sb.String())
}
}
type fakeOut struct{ b *strings.Builder }
func (f *fakeOut) WriteString(s string) (int, error) { return f.b.WriteString(s) }
func (f *fakeOut) Write(p []byte) (int, error) { return f.b.Write(p) }
func TestCollapsedDragCopiesNothing(t *testing.T) {
m := selModel(t)
reg := chatRegionOf(t, m)
m.selStart(reg.X+2, reg.Y+2, m.face.Render().Regions)
// no motion: a plain click
if text := m.selFinish(m.face.Render().Content, os.Stdout); text != "" {
t.Fatalf("a collapsed drag (click) must copy nothing, got %q", text)
}
}
func TestSelectionHighlightOverlaysWhileInFlight(t *testing.T) {
m := selModel(t)
reg := chatRegionOf(t, m)
frame := m.face.Render().Content
rows := strings.Split(semantic.StripANSI(frame), "\n")
row, col := -1, 0
for i, r := range rows {
if j := strings.Index(r, "the quick brown fox"); j >= 0 {
row, col = i, len([]rune(r[:j]))
break
}
}
m.selStart(col, row, m.face.Render().Regions)
m.selMove(col+len("the quick")-1, row, m.face.Render().Regions)
painted := m.overlaySelection(frame)
if !strings.Contains(painted, "\x1b[7m") {
t.Fatalf("the in-flight drag must paint reverse video over the frame")
}
// the overlay is display-column faithful: the same text, escapes aside
if semantic.StripANSI(painted) != semantic.StripANSI(frame) {
t.Fatal("the highlight overlay must never change the frame's text")
}
_ = reg
}
func TestChromeNeverStartsADrag(t *testing.T) {
m := selModel(t)
reg := chatRegionOf(t, m)
m.pickerOpen = true // the centered picker owns the mouse
if m.selStart(reg.X+2, reg.Y+3, m.face.Render().Regions) {
t.Fatal("chrome that owns the mouse must never start a drag")
}
if m.chromeOwnsMouse() != true {
t.Fatal("chromeOwnsMouse must report the picker")
}
m.pickerOpen = false
if m.chromeOwnsMouse() {
t.Fatal("chromeOwnsMouse must release with the picker closed")
}
}
func TestMotionRoutesThroughShell(t *testing.T) {
m := selModel(t)
reg := chatRegionOf(t, m)
next, _ := m.Update(tea.MouseClickMsg{X: reg.X + 2, Y: reg.Y + 3,
Button: tea.MouseLeft})
m = next.(model)
if !m.sel.inFlight() {
t.Fatal("the click must arm the selection")
}
next, _ = m.Update(tea.MouseMotionMsg{X: reg.X + 6, Y: reg.Y + 3,
Button: tea.MouseLeft})
m = next.(model)
if m.sel.head != reg.X+6 {
t.Fatalf("motion with the button held must extend the head: %+v", m.sel)
}
next, _ = m.Update(tea.MouseReleaseMsg{X: reg.X + 6, Y: reg.Y + 3,
Button: tea.MouseLeft})
m = next.(model)
if m.sel.inFlight() {
t.Fatal("the release must finish the selection")
}
}