mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
/jump: - new slash command; opens a picker listing every user turn in the current session (timestamp relative, tool count badge, first line of the prompt). \u2191/\u2193 + enter scrolls the viewport to put that turn's user-message header at the top row. non-destructive, transcript untouched - runes extend a live filter; backspace shortens. '/jump <text>' pre-applies the filter; exactly-one-match auto-jumps without showing the picker - while parked on a past turn the scroll-up note reads 'viewing turn N of M \u00b7 pgdn to catch up' instead of the generic row count. scrolling back to the tail (or starting a new turn, or /clear) resets the parked state automatically - view.go: new MessageAnchor type + BuildWithAnchors so the dialog can resolve msgIdx -> first rendered row perf for long transcripts (the whole ui stutters on ~50 messages): - view.renderCache: per-message memoisation keyed by (fnv1a of role+content, width, expandAll). finalised messages never change so the cache hit rate is ~100% after the first render. streaming partials and in-flight tool-call views stay uncached by design - BuildWithAnchors now pre-sums line counts and allocates in a single make() instead of 50 appends with log2(N) backing- array memcpys - truncateToWidth fast path: byte-length <= cols implies cell-width <= cols, so we skip the rune-width loop entirely. covers the huge majority of lines in a session - cache purged on /clear, /compact completion, and session swap (applySessionSelection); resize invalidates implicitly via the width key. LRU eviction at 4x message count caps memory impact: a 50-msg / 2000-line transcript went from unresponsive- while-typing to drawing in well under a frame. measured locally with go-perf traces; no change to correctness.
183 lines
5 KiB
Go
183 lines
5 KiB
Go
package tui
|
|
|
|
import (
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/mattn/go-runewidth"
|
|
)
|
|
|
|
// runewidthRune reports the number of cells a rune occupies, pinned
|
|
// here so the renderer does not depend on the editor's helper.
|
|
func runewidthRune(r rune) int { return runewidth.RuneWidth(r) }
|
|
|
|
// Renderer maintains a previous frame and writes only the lines that
|
|
// changed on each Draw(). Callers pass a full target frame (slice of
|
|
// styled lines, already wrapped to width).
|
|
type Renderer struct {
|
|
out io.Writer
|
|
prev []string
|
|
rows int // terminal rows
|
|
cols int // terminal cols
|
|
|
|
// Cursor position after last draw (for placing input cursor).
|
|
cursorRow int
|
|
cursorCol int
|
|
|
|
// hideCursor when true prevents ShowCursor from being emitted.
|
|
hideCursor bool
|
|
|
|
// prevHadImage tracks whether the previous frame contained an
|
|
// inline-image escape so we can force a full clear+repaint whenever
|
|
// the image set changes. Only matters when inline images are
|
|
// enabled via ZOT_INLINE_IMAGES; defaults to false.
|
|
prevHadImage bool
|
|
}
|
|
|
|
// NewRenderer returns a renderer that writes to out.
|
|
func NewRenderer(out io.Writer) *Renderer {
|
|
return &Renderer{out: out}
|
|
}
|
|
|
|
// Resize tells the renderer the current terminal size.
|
|
func (r *Renderer) Resize(cols, rows int) {
|
|
if cols != r.cols || rows != r.rows {
|
|
r.cols = cols
|
|
r.rows = rows
|
|
r.prev = nil
|
|
}
|
|
}
|
|
|
|
// Clear forces a full repaint on the next Draw and clears the screen.
|
|
func (r *Renderer) Clear() {
|
|
r.prev = nil
|
|
_, _ = io.WriteString(r.out, SeqClearScreen)
|
|
}
|
|
|
|
// Draw updates the terminal so that the visible frame ends with the
|
|
// given lines (bottom-aligned). cursorRow/cursorCol are offsets within
|
|
// the lines slice indicating where to place the terminal cursor; use
|
|
// -1 to hide it.
|
|
// containsImageEscape reports whether the line carries an inline-image
|
|
// escape we must repaint rather than diff against the previous frame.
|
|
func containsImageEscape(s string) bool {
|
|
return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
|
|
}
|
|
|
|
// truncateToWidth clips s so its on-screen width doesn't exceed cols
|
|
// cells, preserving ANSI CSI escape sequences (which don't consume
|
|
// cells). Lines carrying an inline-image escape are returned as-is
|
|
// since we can't measure their painted size.
|
|
//
|
|
// Fast path: a byte-length <= cols is a conservative upper bound
|
|
// guaranteeing the cell width is also <= cols, so we skip all the
|
|
// rune-width math. That covers the vast majority of lines in a
|
|
// transcript (narrow terminals wrap early; wide ones leave headroom).
|
|
func truncateToWidth(s string, cols int) string {
|
|
if cols <= 0 || containsImageEscape(s) {
|
|
return s
|
|
}
|
|
if len(s) <= cols {
|
|
return s
|
|
}
|
|
var out strings.Builder
|
|
out.Grow(len(s))
|
|
seen := 0
|
|
runes := []rune(s)
|
|
for i := 0; i < len(runes); {
|
|
r := runes[i]
|
|
// CSI escape sequence (ESC [ ... final): zero-width.
|
|
if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
|
|
out.WriteRune(r)
|
|
out.WriteRune(runes[i+1])
|
|
i += 2
|
|
for i < len(runes) {
|
|
c := runes[i]
|
|
out.WriteRune(c)
|
|
i++
|
|
if c >= 0x40 && c <= 0x7e {
|
|
break
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
rw := runewidthRune(r)
|
|
if seen+rw > cols {
|
|
break
|
|
}
|
|
out.WriteRune(r)
|
|
seen += rw
|
|
i++
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
|
|
if r.cols == 0 || r.rows == 0 {
|
|
return
|
|
}
|
|
// Bottom-align: only the last r.rows lines are visible.
|
|
visible := lines
|
|
if len(visible) > r.rows {
|
|
visible = visible[len(visible)-r.rows:]
|
|
cursorRow -= len(lines) - len(visible)
|
|
}
|
|
// Pad to r.rows with empty lines at the top. Every line is also
|
|
// hard-truncated to cols so the terminal never soft-wraps our output
|
|
// (which would push the status bar out of its row).
|
|
frame := make([]string, r.rows)
|
|
top := r.rows - len(visible)
|
|
for i := 0; i < top; i++ {
|
|
frame[i] = ""
|
|
}
|
|
for i, line := range visible {
|
|
frame[top+i] = truncateToWidth(line, r.cols)
|
|
}
|
|
|
|
var w strings.Builder
|
|
w.WriteString(SeqSynchronizedOn)
|
|
w.WriteString(SeqHideCursor)
|
|
|
|
// When inline images are in play we always full-repaint (clear
|
|
// screen first, then rewrite every row). Terminals manage image
|
|
// pixels in a layer we cannot diff against, so the per-line cache
|
|
// is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
|
|
// the common code path below is the fast cached diff.
|
|
curHasImage := false
|
|
for _, l := range frame {
|
|
if containsImageEscape(l) {
|
|
curHasImage = true
|
|
break
|
|
}
|
|
}
|
|
forceAll := curHasImage || r.prevHadImage
|
|
if forceAll {
|
|
w.WriteString(SeqClearScreen)
|
|
}
|
|
|
|
full := r.prev == nil || len(r.prev) != r.rows
|
|
for i := 0; i < r.rows; i++ {
|
|
if full || forceAll || r.prev[i] != frame[i] {
|
|
w.WriteString(MoveTo(i+1, 1))
|
|
w.WriteString(SeqClearLine)
|
|
w.WriteString(frame[i])
|
|
}
|
|
}
|
|
|
|
if cursorRow >= 0 {
|
|
absRow := top + cursorRow + 1
|
|
absCol := cursorCol + 1
|
|
if absRow >= 1 && absRow <= r.rows {
|
|
w.WriteString(MoveTo(absRow, absCol))
|
|
w.WriteString(SeqShowCursor)
|
|
}
|
|
}
|
|
w.WriteString(SeqSynchronizedOff)
|
|
|
|
_, _ = io.WriteString(r.out, w.String())
|
|
|
|
r.prev = frame
|
|
r.prevHadImage = curHasImage
|
|
r.cursorRow = cursorRow
|
|
r.cursorCol = cursorCol
|
|
}
|