Files
zero/internal/tui/render_cache.go
Kevin CodexandGnanam 142c548c89 feat(tui): FILES sidebar panel with click-to-select and file drill-in (#365)
* feat(tui): FILES sidebar panel with click-to-select and file drill-in

Add a FILES section to the right context sidebar showing the files the
session has touched — newest first, with A/M status badges, aggregated
+/− diffstats, and a pulsing row for the file being written right now.

Clicking a row selects the file (accent marker, edit cards tint in the
chat, transcript scrolls to the latest edit); a second click opens a
drill-in view: the chat column swaps to the file's stacked edit cards,
`f` toggles to the syntax-highlighted on-disk file with markers on
session-added lines, `d` back to diff, Esc returns to the chat.

The roster is fed from two sources: changedFiles carried by
write_file/edit_file/apply_patch results (persisted per session, so it
survives /resume), and a git sweep — baseline snapshot at startup,
re-check after command tools and at turn end — so files created through
bash/exec_command scaffolding or subagents show up too.

* fix(tui): address FILES sidebar review findings

Review fixes for the FILES panel / drill-in / git sweep:

- touchedFiles: split multi-file diffs per file (perFileDiffStats) so an
  apply_patch spanning several files no longer charges the whole patch's
  +/- totals to every file it touched; order by lastRowIndex descending
  so a re-touched file lists first (reversing first-seen order kept it in
  its original slot).
- sidebarFileLines: filter the live-writing row out before computing the
  "+N more" trailer so the skipped entry no longer inflates the count.
- git sweep: run `git diff --numstat -z` and parse the NUL-terminated
  rename records (counts + preimage + postimage) — plain --numstat
  brace-mangles renamed paths ("src/{old => new}"), so their diffstat
  never matched a porcelain path and renamed files stayed at 0/0;
  gitTouchedFiles now yields newest detection first to match the
  roster's ordering.
- file view: stream the full-mode read and stop at fileViewMaxLines
  (os.ReadFile loaded a multi-GB file wholesale before truncating — the
  exact freeze the cap exists to prevent); re-opening the file already
  being viewed is a no-op so a stray re-click can't bounce full mode
  back to diff or reset scroll; skip sub-4-char lines in the changed-line
  markers so common braces/returns don't mark unrelated occurrences.
- keys: gate the drill-in's d/f and Esc handling on noBlockingModal so a
  permission prompt / ask-user / wizard keeps its own key handling.
- sidebar: a live in-flight write counts as content, so the FILES pulse
  shows for the session's first mutation.
- tests: numstat test now feeds real -z record shapes and asserts the
  rename postimage key; new coverage for per-file stats, re-touch
  ordering, live-row overflow, same-file reopen, truncation, modal
  gating, live-write content, and a real-repo rename sweep.

---------

Co-authored-by: Gnanam <[email protected]>
2026-07-02 12:15:41 +05:30

238 lines
6.2 KiB
Go

package tui
import (
"container/list"
"fmt"
"strconv"
"strings"
"sync"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
)
const (
defaultRenderCacheMaxEntries = 256
defaultRenderCacheMaxCharacters = 512 * 1024
)
var defaultRenderCache = newStaticRenderCache(defaultRenderCacheMaxEntries, defaultRenderCacheMaxCharacters)
type renderCacheStats struct {
Hits int
Misses int
Evictions int
SkippedOversized int
}
type staticRenderCache struct {
mu sync.Mutex
maxEntries int
maxCharacters int
retained int
items map[string]*list.Element
lru *list.List
statsData renderCacheStats
}
type staticRenderCacheEntry struct {
key string
value string
chars int
}
func newStaticRenderCache(maxEntries int, maxCharacters int) *staticRenderCache {
return &staticRenderCache{
maxEntries: maxEntries,
maxCharacters: maxCharacters,
items: map[string]*list.Element{},
lru: list.New(),
}
}
// clear drops every cached render. Called when the active theme changes so stale
// entries painted in the old palette are never reused.
func (c *staticRenderCache) clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.items = map[string]*list.Element{}
c.lru.Init()
c.retained = 0
}
func (c *staticRenderCache) render(key string, stable bool, render func() string) string {
if render == nil {
return ""
}
if c == nil || !stable || key == "" {
return render()
}
c.mu.Lock()
defer c.mu.Unlock()
if element, ok := c.items[key]; ok {
c.statsData.Hits++
c.lru.MoveToFront(element)
return element.Value.(*staticRenderCacheEntry).value
}
c.statsData.Misses++
value := render()
chars := utf8.RuneCountInString(value)
if c.maxEntries <= 0 || c.maxCharacters <= 0 || chars > c.maxCharacters {
c.statsData.SkippedOversized++
return value
}
entry := &staticRenderCacheEntry{key: key, value: value, chars: chars}
c.items[key] = c.lru.PushFront(entry)
c.retained += chars
c.evictOverflow()
return value
}
func (c *staticRenderCache) evictOverflow() {
for len(c.items) > c.maxEntries || c.retained > c.maxCharacters {
element := c.lru.Back()
if element == nil {
c.retained = 0
return
}
entry := element.Value.(*staticRenderCacheEntry)
delete(c.items, entry.key)
c.lru.Remove(element)
c.retained -= entry.chars
c.statsData.Evictions++
}
}
func (c *staticRenderCache) stats() renderCacheStats {
if c == nil {
return renderCacheStats{}
}
c.mu.Lock()
defer c.mu.Unlock()
return c.statsData
}
func (c *staticRenderCache) retainedCharacters() int {
if c == nil {
return 0
}
c.mu.Lock()
defer c.mu.Unlock()
return c.retained
}
func (m model) renderRowCacheKey(row transcriptRow, width int, rc rowContext, opts cardRenderOptions, flush bool) (string, bool) {
stable := true
switch row.kind {
case rowWelcome:
return "", false
case rowToolCall:
if m.pending && row.runID != 0 && row.runID == m.activeRunID {
stable = false
}
case rowPermission:
event := row.permission
if event != nil && event.ToolCallID != "" && event.Action == agent.PermissionActionPrompt &&
!rc.decided[rcKey(row.runID, event.ToolCallID)] &&
m.pending && row.runID != 0 && row.runID == m.activeRunID {
stable = false
}
case rowSpecialist:
if row.specialistInfo != nil && row.specialistInfo.status == specialistRunning {
stable = false
}
}
var b strings.Builder
appendRenderCacheField(&b, "render-row-v1")
appendRenderCacheField(&b, strconv.Itoa(width))
appendRenderCacheField(&b, strconv.FormatBool(flush))
appendRenderCacheField(&b, strconv.Itoa(opts.bodyCap))
appendRenderCacheField(&b, strconv.FormatBool(opts.expanded))
appendRenderCacheField(&b, opts.cwd)
appendRenderCacheField(&b, strconv.Itoa(int(row.kind)))
appendRenderCacheField(&b, row.id)
appendRenderCacheField(&b, row.text)
appendRenderCacheField(&b, row.tool)
appendRenderCacheField(&b, fmt.Sprint(row.status))
appendRenderCacheField(&b, row.detail)
appendRenderCacheField(&b, row.arg)
appendRenderCacheField(&b, strconv.Itoa(row.runID))
appendRenderCacheField(&b, strconv.FormatBool(row.expanded))
appendRenderCacheField(&b, strconv.FormatBool(row.final))
appendRenderCacheField(&b, strconv.Itoa(row.turnTools))
appendRenderCacheField(&b, strconv.FormatInt(int64(row.turnElapsed), 10))
// The FILES selection tints this row's card border, so selecting/deselecting
// a file must miss the cache entry rendered under the other state.
appendRenderCacheField(&b, strconv.FormatBool(m.rowTouchesSelectedFile(row)))
key := rcKey(row.runID, row.id)
appendRenderCacheField(&b, rc.hints[key])
appendRenderCacheField(&b, rc.args[key])
appendRenderCacheField(&b, strconv.FormatBool(rc.auto[key]))
appendRenderCacheField(&b, permissionCacheFingerprint(row.permission))
appendRenderCacheField(&b, askUserCacheFingerprint(row.askUser))
return b.String(), stable
}
func appendRenderCacheField(b *strings.Builder, value string) {
b.WriteString(strconv.Itoa(len(value)))
b.WriteByte(':')
b.WriteString(value)
b.WriteByte('|')
}
func permissionCacheFingerprint(event *agent.PermissionEvent) string {
if event == nil {
return ""
}
fields := []string{
event.ToolCallID,
event.ToolName,
string(event.Action),
string(event.DecisionAction),
event.Permission,
string(event.PermissionMode),
event.Autonomy,
event.SideEffect,
event.Reason,
event.Scope,
string(event.Risk.Level),
strconv.FormatBool(event.GrantMatched),
strconv.FormatBool(event.Grant != nil),
strconv.FormatBool(event.Grant != nil && event.Grant.Session),
}
if event.Block != nil {
fields = append(fields,
string(event.Block.Code),
string(event.Block.Risk.Level),
event.Block.Path,
event.Block.Reason,
)
}
var b strings.Builder
for _, field := range fields {
appendRenderCacheField(&b, field)
}
return b.String()
}
func askUserCacheFingerprint(request *agent.AskUserRequest) string {
if request == nil {
return ""
}
var b strings.Builder
appendRenderCacheField(&b, request.ToolCallID)
appendRenderCacheField(&b, request.Header)
for _, question := range request.Questions {
appendRenderCacheField(&b, question.Question)
appendRenderCacheField(&b, strconv.FormatBool(question.MultiSelect))
for _, option := range question.Options {
appendRenderCacheField(&b, option)
}
}
return b.String()
}