Something went wrong. Try again.
small gleam coding and persistent agent daemon with a detachable cli and a canvas ui forked from okami.mom/albedo
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260package tui
import ( "math" "slices" "strings"
"github.com/charmbracelet/x/ansi" "github.com/rivo/uniseg")
// Row marks are zero-width APC strings that widths and ansi.Strip skip.// They tell a copy what a row is beyond its text. AppModel.View removes them// before Bubble Tea renders; they must never reach the terminal.const ( // markChrome labels the transcript rather than being part of it: who // speaks, a turn's signoff, tool glances. markChrome = "\x1b_albedo:chrome\x1b\\" // markWrap continues the row above, which broke at a space, and // markSplit continues it mid-word. markWrap = "\x1b_albedo:wrap\x1b\\" markSplit = "\x1b_albedo:split\x1b\\")
// chrome marks every row of s.func chrome(s string) string { return markChrome + strings.ReplaceAll(s, "\n", "\n"+markChrome)}
// wrapJoiner is what goes between a row and the row above it continues.func wrapJoiner(line string) (string, bool) { if strings.Contains(line, markWrap) { return " ", true } return "", strings.Contains(line, markSplit)}
// markChunks marks the rows line wrapped into as continuing the one before,// and gives them the line's chrome.func markChunks(line string, chunks []string) []string { carried := "" if strings.Contains(line, markChrome) { carried = markChrome } for i := 1; i < len(chunks); i++ { mark := markSplit if strings.HasSuffix(ansi.Strip(chunks[i-1]), " ") { mark = markWrap } chunks[i] = carried + mark + chunks[i] } return chunks}
// markWraps marks the rows of wrapped, markdown rendered to a width, that// continue the row above. flat is the same markdown unwrapped: a run of// wrapped rows that spells one of its lines, in order, is that line. A row// that spells none, like a table's, stays a line of its own.func markWraps(wrapped, flat string) string { rows := strings.Split(wrapped, "\n") var lines []string for line := range strings.SplitSeq(flat, "\n") { lines = append(lines, rowText(line)) } // rest is what the line being spelled has left, after a space when spaced next, rest, spaced := 0, "", false for i, row := range rows { text := rowText(row) if cont := strings.TrimLeft(text, " "); rest != "" && cont != "" && strings.HasPrefix(rest, cont) { mark := markSplit if spaced { mark = markWrap } rows[i] = mark + row rest, spaced = spell(rest, cont) continue } rest = "" // rows that spell nothing hold the search back, so it looks ahead // past the flat lines they stand for for k := next; k < min(len(lines), next+16); k++ { if lines[k] == text || text != "" && strings.HasPrefix(lines[k], text) { next = k + 1 rest, spaced = spell(lines[k], text) break } } } return strings.Join(rows, "\n")}
// spell is what line has left after text, and whether spaces came first.func spell(line, text string) (string, bool) { after := line[len(text):] rest := strings.TrimLeft(after, " ") return rest, len(rest) < len(after)}
// rowText is what a row says: its plain text without quote bars or padding.func rowText(line string) string { _, text, _ := selectedRange(line, 0, math.MaxInt, quoteColumns(line)) return strings.TrimRight(text, " ")}
type Point struct { Row int Col int}type Selection struct { Anchor Point Head Point // Gutter is the decoration at the start of every row, which neither // the highlight nor the copied text includes. Gutter int}
func (s Selection) IsEmpty() bool { return s.Anchor == s.Head }func (s Selection) Normalized() (Point, Point) { if s.Anchor.Row < s.Head.Row || (s.Anchor.Row == s.Head.Row && s.Anchor.Col <= s.Head.Col) { return s.Anchor, s.Head } return s.Head, s.Anchor}
// selectedRange splits line around the columns from to to. Columns in skip// are left out of the selection.func selectedRange(line string, from, to int, skip map[int]bool) (before, selected, after string) { plain := ansi.Strip(line) var chosen, prefix, suffix strings.Builder col := 0 for len(plain) > 0 { cluster, rest, _, _ := uniseg.FirstGraphemeClusterInString(plain, -1) width := ansi.StringWidth(cluster) switch { case col+width > from && col < to: if !skip[col] { chosen.WriteString(cluster) } case col < from: prefix.WriteString(cluster) default: suffix.WriteString(cluster) } plain = rest col += width } return prefix.String(), chosen.String(), suffix.String()}
// selBounds is the column range of sel on one row of lines.func selBounds(line string, sel Selection, start, end Point, row int) (int, int) { from, to := sel.Gutter, ansi.StringWidth(line) if row == start.Row { from = max(from, start.Col) } if row == end.Row { to = end.Col } return from, to}
// SelectedText is the text under sel: what the rows say, without what the// transcript draws around it. Code block frames and quote bars are left out,// and so are chrome rows unless nothing else is selected; wrapped rows join// back into their lines.func SelectedText(lines []string, sel Selection) string { if sel.IsEmpty() || len(lines) == 0 { return "" } start, end := sel.Normalized() type piece struct { text string joiner string chrome, joins bool } var picked []piece onlyChrome := true for row := max(0, start.Row); row <= end.Row && row < len(lines); row++ { line := lines[row] if strings.Contains(line, codeFence(fenceOpen)) { continue } // a code block's closing frame also spaces the block from what // follows, so it copies as a blank row if strings.Contains(line, codeFence(fenceClose)) { picked = append(picked, piece{}) continue } from, to := selBounds(line, sel, start, end, row) _, text, _ := selectedRange(line, from, to, quoteColumns(line)) if act, ok := actionOf(line); ok && act.verb == verbOpen { // the toggle is a control, not part of what the row says text = strings.TrimPrefix(strings.TrimPrefix(text, toggleClosed), toggleOpen) } // rows are padded out to the viewport's width p := piece{text: strings.TrimRight(text, " "), chrome: strings.Contains(line, markChrome)} if row > start.Row { p.joiner, p.joins = wrapJoiner(line) } onlyChrome = onlyChrome && (p.chrome || p.text == "") picked = append(picked, p) } var rows []string // joinable says the last row kept is the row above; dropped says chrome // was left out since the last text, so a blank row after it is spare joinable, dropped := false, false for _, p := range picked { switch { case p.chrome && !onlyChrome: joinable, dropped = false, true case p.joins && joinable: rows[len(rows)-1] += p.joiner + strings.TrimLeft(p.text, " ") case p.text == "" && dropped && len(rows) > 0 && rows[len(rows)-1] == "": joinable = false default: rows = append(rows, p.text) joinable, dropped = true, dropped && p.text == "" } } // and the viewport's height, so blank rows at either end are padding too for len(rows) > 0 && rows[0] == "" { rows = rows[1:] } for len(rows) > 0 && rows[len(rows)-1] == "" { rows = rows[:len(rows)-1] } return strings.Join(rows, "\n")}
// quoteColumns are the columns of line under quote bars.func quoteColumns(line string) map[int]bool { bar := quoteBar() skip := map[int]bool{} for at := 0; ; at += len(bar) { i := strings.Index(line[at:], bar) if i < 0 { return skip } at += i col := ansi.StringWidth(line[:at]) skip[col], skip[col+1] = true, true }}
func HighlightSelection(lines []string, sel Selection) []string { if sel.IsEmpty() { return lines } start, end := sel.Normalized() out := slices.Clone(lines) for row := max(0, start.Row); row <= end.Row && row < len(out); row++ { from, to := selBounds(out[row], sel, start, end, row) before, text, after := selectedRange(out[row], from, to, nil) if text != "" { out[row] = before + DefaultStyles.Cursor.Render(text) + after } } return out}