mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* 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]>
125 lines
5.1 KiB
Go
125 lines
5.1 KiB
Go
package tui
|
|
|
|
import tea "charm.land/bubbletea/v2"
|
|
|
|
// hoverKind discriminates which clickable surface a hoverTarget refers to.
|
|
type hoverKind int
|
|
|
|
const (
|
|
hoverNone hoverKind = iota
|
|
// hoverTranscript: a specialist card or collapse/expand toggle header in the
|
|
// main transcript body (or the subchat child view), identified by bodyY.
|
|
hoverTranscript
|
|
// hoverSidebarAgent: a clickable AGENTS sidebar row, identified by sessionID.
|
|
hoverSidebarAgent
|
|
// hoverPlanStep: a plan step row in the sidebar's PLAN section, identified by
|
|
// step index.
|
|
hoverPlanStep
|
|
// hoverFileRow: a touched-file row in the sidebar's FILES section, identified
|
|
// by path.
|
|
hoverFileRow
|
|
)
|
|
|
|
// hoverTarget identifies the single clickable row (if any) currently under the
|
|
// mouse cursor with no button pressed. Exactly one of bodyY/sessionID/stepIndex is
|
|
// meaningful, discriminated by kind.
|
|
//
|
|
// The sidebar fields deliberately store a STABLE IDENTITY (sessionID, stepIndex),
|
|
// not a raw line offset: the AGENTS/PLAN section sizes can change between when a
|
|
// hover is detected and when the sidebar next renders (a swarm member's linger
|
|
// window elapsing, a plan step completing) with no mouse motion in between to
|
|
// re-resolve it. A cached raw offset would then silently point at whatever
|
|
// unrelated row now occupies that slot. hoveredSidebarLineOffset (sidebar.go)
|
|
// re-resolves the CURRENT line offset for this identity fresh on every render, so
|
|
// a row that's gone simply doesn't highlight rather than a wrong one lighting up.
|
|
type hoverTarget struct {
|
|
kind hoverKind
|
|
bodyY int // hoverTranscript
|
|
sessionID string // hoverSidebarAgent
|
|
stepIndex int // hoverPlanStep
|
|
filePath string // hoverFileRow
|
|
}
|
|
|
|
// mouseHover reports whether msg is a plain cursor-movement event with NO button
|
|
// pressed — a hover, not a drag. Requires AllMotion mouse reporting (see View's
|
|
// wantsMouseCapture branch); CellMotion only reports motion while a button is held,
|
|
// so this predicate would never match under the old mouse mode.
|
|
func mouseHover(msg tea.MouseMsg) bool {
|
|
if _, ok := msg.(tea.MouseMotionMsg); !ok {
|
|
return false
|
|
}
|
|
return mouseEvent(msg).Button == tea.MouseNone
|
|
}
|
|
|
|
// updateHoverTarget resolves what's under the cursor for a hover motion, using
|
|
// the SAME hit-testers and priority order as the click handlers in
|
|
// handleTranscriptSelectionMouse's press case: a sidebar row (agent, then plan
|
|
// step) takes priority since it's outside the chat column, then a clickable
|
|
// transcript line. Clears the hover when nothing clickable is under the cursor.
|
|
func (m model) updateHoverTarget(msg tea.MouseMsg) model {
|
|
if hit, ok := m.sidebarLineAtMouse(msg); ok {
|
|
return m.withHover(hoverTarget{kind: hoverSidebarAgent, sessionID: hit.sessionID})
|
|
}
|
|
if stepIndex, ok := m.planStepAtMouse(msg); ok {
|
|
return m.withHover(hoverTarget{kind: hoverPlanStep, stepIndex: stepIndex})
|
|
}
|
|
if path, ok := m.fileRowAtMouse(msg); ok {
|
|
return m.withHover(hoverTarget{kind: hoverFileRow, filePath: path})
|
|
}
|
|
if line, ok := m.transcriptLineAtMouse(msg); ok {
|
|
// A permission option reuses its OWN existing keyboard-cursor highlight
|
|
// (see hoverPermissionOption) rather than the m.hover mechanism, so there's
|
|
// nothing further to set here.
|
|
if line.permOption {
|
|
return m.hoverPermissionOption(line.permChoice).withHover(hoverTarget{})
|
|
}
|
|
// Only a card or a collapse/expand toggle header is "clickable" here; plain
|
|
// selectable text (e.g. a user/assistant message) isn't, so hovering over
|
|
// it must not light up as if it were.
|
|
if line.specialistCard || line.toggle {
|
|
return m.withHover(hoverTarget{kind: hoverTranscript, bodyY: line.bodyY})
|
|
}
|
|
}
|
|
return m.withHover(hoverTarget{})
|
|
}
|
|
|
|
// hoverPermissionOption moves the pending permission prompt's cursor to the
|
|
// hovered option, reusing its EXISTING keyboard-navigation highlight (the same
|
|
// one Tab/Shift+Tab/arrow keys move) instead of new styling — the popup already
|
|
// renders whichever option .cursor points at differently. A no-op when no
|
|
// permission prompt is pending or the choice isn't found (defensive; every
|
|
// permOption line's choice always comes from permissionOptions(request) in the
|
|
// first place).
|
|
func (m model) hoverPermissionOption(choice permissionDecision) model {
|
|
if m.pendingPermission == nil {
|
|
return m
|
|
}
|
|
for index, option := range permissionOptions(m.pendingPermission.request) {
|
|
if option.choice == choice {
|
|
m.pendingPermission.cursor = index
|
|
return m
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m model) withHover(t hoverTarget) model {
|
|
m.hover = t
|
|
return m
|
|
}
|
|
|
|
// clearHover drops any hover highlight. Called wherever the underlying content a
|
|
// hover target refers to may no longer mean the same thing on the next render — a
|
|
// wheel-scroll (the cursor's mapping to a bodyY is only recomputed on the NEXT
|
|
// motion event; without clearing, a stale bodyY could coincidentally match a
|
|
// DIFFERENT row after the viewport shifts, highlighting the wrong thing) or a
|
|
// subchat toggle (bodyY numbering is entirely different between the parent
|
|
// transcript and a child session).
|
|
func (m model) clearHover() model {
|
|
if m.hover.kind == hoverNone {
|
|
return m
|
|
}
|
|
m.hover = hoverTarget{}
|
|
return m
|
|
}
|