package tui
import (
"fmt"
"regexp"
"slices"
"strings"
"time"
tea "charm.land/bubbletea/v2"
)
// A row action is a click target. Like the copy marks in selection.go, its
// mark is an APC string that draws nothing, so the row itself says what a
// click on it does and survives wrapping, trimming, and rebuilds.
const (
// verbCopy copies a turn's replies as the markdown they were written in.
verbCopy = "copy"
// verbOpen lists or hides the steps of a burst.
verbOpen = "open"
// verbMore shows or folds the rest of a long message of yours.
verbMore = "more"
)
var actionMark = regexp.MustCompile(`\x1b_albedo:(copy|open|more):([0-9a-f]+-[0-9a-f]+)\x1b\\`)
type rowAction struct{ verb, key string }
func (a rowAction) mark() string { return "\x1b_albedo:" + a.verb + ":" + a.key + "\x1b\\" }
func actionOf(row string) (rowAction, bool) {
hit := actionMark.FindStringSubmatch(row)
if hit == nil {
return rowAction{}, false
}
return rowAction{verb: hit[1], key: hit[2]}, true
}
// entryKey names an entry by what it says, so the mark on its rows still
// finds it after the transcript is rebuilt.
func entryKey(entry HistoryEntry) string {
h := fnv1a(fnvOffset64, string(entry.Kind)+"\x00"+entry.ToolName+"\x00"+entry.Text+"\x00"+entry.ToolResult+"\x00"+fmt.Sprint(entry.ToolArgs))
return fmt.Sprintf("%x-%x", entry.Timestamp, h)
}
// actAt runs the action of the transcript row at absolute index row.
func (m *ChatModel) actAt(row int) tea.Cmd {
if row < 0 || row >= len(m.frameLines) {
return nil
}
act, ok := actionOf(m.frameLines[row])
if !ok {
return nil
}
switch act.verb {
case verbCopy:
return m.copyReply(act.key)
case verbOpen:
m.toggleBurst(act.key, row)
case verbMore:
m.toggleFold(act.key, row)
}
return nil
}
// toggleFold shows or folds the rest of the message whose fold row is at row,
// the last row of its block. Only that block renders again and is spliced into
// the settled rows. The clicked row keeps its place on screen: expanding opens
// the text below it, and folding brings the new fold row to where it was.
func (m *ChatModel) toggleFold(key string, row int) {
entries := m.History.Entries()
at := slices.IndexFunc(entries, func(e HistoryEntry) bool { return e.Kind == EntryUser && entryKey(e) == key })
if at < 0 {
return
}
was := m.Renderer
was.Open = map[string]bool{key: m.Renderer.Open[key]}
old, _ := was.Block(entries[:at], entries[at], m.Flags)
if m.Renderer.Open == nil {
m.Renderer.Open = map[string]bool{}
}
m.Renderer.Open[key] = !m.Renderer.Open[key]
fresh, _ := m.Renderer.Block(entries[:at], entries[at], m.Flags)
from := row - m.settledOffset - (len(old) - 1)
if from < 0 || from+len(old) > len(m.settledLines) || m.settledLines[from+len(old)-1] != old[len(old)-1] {
m.rebuildSettledLines()
m.refreshViewportContent()
return
}
screen := row - m.scrollOffset
if !m.Renderer.Open[key] {
row += len(fresh) - len(old)
}
row -= m.replaceSettled(from, len(old), fresh)
m.Follow, m.scrollOffset = false, max(0, row-screen)
m.Follow = m.scrollOffset >= m.refreshViewportContent()
}
// toggleBurst opens or closes the burst whose summary is at row, keeping that
// row where it was on screen; an opened list that would end below the screen
// scrolls up into view. Only the burst renders again: a settled one is
// spliced into the settled rows, and the live one redraws on its own.
func (m *ChatModel) toggleBurst(key string, row int) {
entries := m.History.Entries()
start, end, ok := burstNamed(entries, key, m.Flags)
if !ok {
return
}
before, burst := entries[:start], entries[start:end]
was := m.Renderer
was.Open = map[string]bool{key: m.Renderer.Open[key]}
old := was.BurstBlock(before, burst, m.Flags)
if m.Renderer.Open == nil {
m.Renderer.Open = map[string]bool{}
}
m.Renderer.Open[key] = !m.Renderer.Open[key]
fresh := m.Renderer.BurstBlock(before, burst, m.Flags)
// the summary sits after the same separator in both
head := slices.IndexFunc(fresh, func(line string) bool {
_, ok := actionOf(line)
return ok
})
screen := row - m.scrollOffset
if end < len(entries) {
from := row - m.settledOffset - head
if from < 0 || from+len(old) > len(m.settledLines) || m.settledLines[from+head] != old[head] {
m.rebuildSettledLines()
m.refreshViewportContent()
return
}
row -= m.replaceSettled(from, len(old), fresh)
}
m.burstEpoch++
offset, last := row-screen, row-head+len(fresh)
if last-offset > m.Viewport.Height() {
offset = min(row, last-m.Viewport.Height())
}
m.Follow, m.scrollOffset = false, max(0, offset)
m.Follow = m.scrollOffset >= m.refreshViewportContent()
}
// copyReply copies a reply's markdown.
func (m *ChatModel) copyReply(key string) tea.Cmd {
if text, ok := m.replyMarkdown(key); ok {
return m.copied(text)
}
return nil
}
// replyMarkdown is a finished turn's prose as written: every reply since
// your message, which the turn's signoff names.
func (m *ChatModel) replyMarkdown(key string) (string, bool) {
entries := m.History.Entries()
end := slices.IndexFunc(entries, func(e HistoryEntry) bool { return e.Kind == EntryTurnEnd && entryKey(e) == key })
if end < 0 {
return "", false
}
start := end
for start > 0 && entries[start-1].Kind != EntryUser {
start--
}
var parts []string
for _, e := range entries[start:end] {
if e.Kind == EntryAssistant {
parts = append(parts, e.Text)
}
}
return strings.Join(parts, "\n\n"), len(parts) > 0
}
func (m *ChatModel) copied(text string) tea.Cmd {
m.CopyStatus = "copied"
m.copyStatusRevision++
return tea.Batch(CopyText(text), m.clearCopyStatusCmd())
}
// Dragging a selection past the top or bottom edge of the transcript
// scrolls it: after a short delay, then a row at a time while the pointer
// stays there. The selection is in transcript rows, so it keeps growing.
const (
dragScrollDelay = 150 * time.Millisecond
dragScrollEvery = 50 * time.Millisecond
)
type dragScrollMsg struct{ gen int }
func dragScrollTick(gen int, after time.Duration) tea.Cmd {
return tea.Tick(after, func(time.Time) tea.Msg { return dragScrollMsg{gen} })
}
// steerDragScroll starts or stops the edge scroll for a pointer on screen
// row of the transcript.
func (m *ChatModel) steerDragScroll(row int) tea.Cmd {
dir := 0
switch {
case row <= 0 && m.scrollOffset > 0:
dir = -1
case row >= m.Viewport.Height()-1 && !m.Follow:
dir = 1
}
if dir == m.dragDir {
return nil
}
m.dragDir = dir
m.dragGen++
if dir == 0 {
return nil
}
return dragScrollTick(m.dragGen, dragScrollDelay)
}
// dragScrolled moves one row toward the edge and pulls the selection's head
// along, or stops when the transcript has no more to show that way.
func (m *ChatModel) dragScrolled(msg dragScrollMsg) tea.Cmd {
if m.dragAnchor == nil || msg.gen != m.dragGen || m.dragDir == 0 {
return nil
}
before := m.scrollOffset
m.scrollBy(m.dragDir)
if m.scrollOffset == before {
m.dragDir = 0
return nil
}
m.dragHead.Row = m.scrollOffset
if m.dragDir >= 0 {
m.dragHead.Row += m.Viewport.Height() - 1
}
return dragScrollTick(m.dragGen, dragScrollEvery)
}