mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
perf(tui): bounded file read, LRU rendering cache, and size caps for full-file view (#1203)
* perf(tui): bounded file read, LRU rendering cache, and size caps for full-file view - Bound full-file view reads (4000 lines, 1 MiB total, 4 KiB per line) with async loading and an LRU render cache capped at 64 entries and 8 MiB. - Refresh both BTW surfaces when a tool result (such as write_stdin) carries both exact changed paths and an unknown-scope or aggregated directory summary outside Git. - Gate Run details FILES mouse hit-testing on Run details being the visible foreground overlay so higher-priority modals (speech API-key prompt, leader help) block clicks behind them. (cherry picked from commit100d847aaf) * fix(tui): gate full-view read errors on snapshot validity renderFileViewFull returned renderedContent whenever hasError was set, before the snapshot-validity check that governs every other completed snapshot. A /btw fork of a full view that had settled on a read error invalidates the inherited snapshot and clears renderedContent, but the error latch survived, so the side pane rendered an empty body instead of the loading placeholder until its own load landed. Reloads of an errored view (resize, refresh, theme) likewise kept showing the stale error. Serve the error snapshot through the same validity predicate as content snapshots, so a pending or invalidated load falls through to "Loading…". Error displays still do not count as cache hits. Co-Authored-By: Claude Opus 5.5 <[email protected]> (cherry picked from commita1887f4b03) * fix(tui): keep file-view revisions monotonic and revalidate on git sweeps Shared path revisions reset to zero on every cache purge, while each surface kept its own required revision. Three sites could also raise that revision past the shared one by incrementing it locally. After a purge, an exact-path edit made on the other BTW surface could produce a shared revision no higher than this surface's own. recoverInvalidatedFileView then saw nothing new, and the view stayed stale. Revisions now come from one cache-wide clock that survives purges. A purge raises the epoch floor to that clock instead of resetting it, and each surface adopts the revision the cache returns. Every git sweep invalidated each swept path and forced a full refresh. The swept list is cumulative since the baseline, and each live sweep is launched beside the tool-result, turn-end or BTW-return handling that already reloads or rechecks the open view. The sweep that followed therefore reread and re-highlighted an unchanged file and flashed "Loading…". A sweep now revalidates the open view instead. It waits for an in-flight load to land, then rereads the bounded source in the background and compares it with the displayed snapshot's identity (hash and truncation flags). The view reloads only if the bytes changed. A mutation the swept list omits is still caught. Regression tests (all fail on311290c0and pass here): - TestFileViewCrossSurfaceExactEditAfterPurgeReloadsOtherSurface - TestFileViewGitSweepRevalidatesWithoutDuplicateReload - TestFileViewGitSweepDuringLoadDefersToThatLoad - TestFileViewStaleRevalidationDoesNotReloadNewerSnapshot - TestFileViewCachePurgeResetsPathRevisions (now asserts monotonicity) Co-Authored-By: Claude Opus 5.5 <[email protected]> * refactor(tui): return source identity from loadAndRender directly1ce4f96eadded loadAndRenderSource and left loadAndRender as a two-value wrapper. After that change only tests called the wrapper, so make deadcode newly reported it. Fold the wrapper away: loadAndRender now returns the rendered text, the source identity, and the error, and its callers take the value they need. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(tui): close three file-view staleness gaps from review - fileViewSourceHash joined lines with separators, so an empty file (no lines) and a file holding one newline (one empty line) hashed alike. Sweep revalidation compares identities without the file size, so adding or removing that blank line went undetected. Every line is now terminated, which keeps the encoding injective. - A load cancelled inside the bounded read returned errFileViewSuperseded with no lines. loadAndRender then treated it as a read error and evicted the path, discarding an entry a newer load had already committed. The sentinel now returns before the eviction branch. - A cancelled run keeps going until it finishes, and agentRowMsg ignores its late tool results. Its completion only flushed session events, so a write that finished during cancellation left an open full-file view stale. The flush completion now runs the unknown-scope refresh. Regression tests (each fails onca1cdaf3and passes here): - TestFileViewGitSweepDetectsBlankLineAddedToEmptyFile - TestFileViewSupersededReadKeepsNewerCacheEntry - TestFileViewCancelledRunCompletionRefreshesOpenView Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(tui): gate Run details Enter on the visible overlay The Run details Enter drill-in keyed off the raw runDetailsOpen flag, while mouse FILES picks use runDetailsVisible(). The flag outlives the overlay: clicking a specialist card beside Run details enters the subchat, which hides the overlay but leaves runDetailsOpen set, and Enter then opened the parent's file view from inside the subchat. Use runDetailsVisible() and the shared runDetailsInnerWidth() geometry on the keyboard path, matching the mouse path. The branch's existing Esc/Ctrl+B and key-swallow behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: hazyhaar <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
18 files changed
+6505
-142
No files matched your search
+76
-8
@@ -91,6 +91,20 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
|
||||
parent.flushQueue = nil
|
||||
|
||||
side := parent
|
||||
// The side surface must own its file-view lifecycle. Because `side` is a
|
||||
// value copy of `parent`, its liveSeq pointer would otherwise alias the
|
||||
// hidden parent's: closing or switching the view in BTW would revoke the
|
||||
// parent's in-flight load. Detach it and give the side a fresh lifetime so
|
||||
// inherited markers cannot masquerade as the parent's snapshot.
|
||||
side.fileView.liveSeq = nil
|
||||
if side.fileView.active {
|
||||
side.fileView.lifetimeToken = nextFileViewLifetimeToken()
|
||||
side.fileView.loadedToken = [16]byte{}
|
||||
side.fileView.loadedSeq = 0
|
||||
side.fileView.loadedRev = 0
|
||||
side.fileView.snapshotReady = false
|
||||
side.fileView.renderedContent = ""
|
||||
}
|
||||
side.activeSession = fork
|
||||
side.sessionEvents = events
|
||||
side.transcript = initialTranscript()
|
||||
@@ -156,10 +170,16 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
|
||||
sideRunIDBase: side.runID,
|
||||
}
|
||||
|
||||
if question == "" {
|
||||
return side, nil
|
||||
var btwFileViewCmd tea.Cmd
|
||||
if side.fileView.active && side.fileView.mode == fileViewFull {
|
||||
side, btwFileViewCmd = side.startFileViewLoadCmd(side.chatColumnWidth())
|
||||
}
|
||||
return side.launchPrompt(question)
|
||||
|
||||
if question == "" {
|
||||
return side, btwFileViewCmd
|
||||
}
|
||||
next, launchCmd := side.launchPrompt(question)
|
||||
return next, batchCommands(btwFileViewCmd, launchCmd)
|
||||
}
|
||||
|
||||
func (m model) leaveBTW() (model, tea.Cmd) {
|
||||
@@ -175,6 +195,10 @@ func (m model) leaveBTW() (model, tea.Cmd) {
|
||||
if m.compactInFlight {
|
||||
return m.appendSystemNotice("BTW compaction is still running. Wait for it to finish before returning."), nil
|
||||
}
|
||||
// The side surface is being discarded. Revoke its file-view lifetime token so
|
||||
// a load still queued behind BTW cannot run or land after the view closes.
|
||||
// The restored parent keeps its own request and snapshot untouched.
|
||||
m.revokeFileViewRequest()
|
||||
m, _ = m.clearLoopsForSessionSwitch()
|
||||
parent := *m.btw.parent
|
||||
parent.goalContinuationsSuspended = false
|
||||
@@ -201,7 +225,9 @@ func (m model) leaveBTW() (model, tea.Cmd) {
|
||||
parent.resetFlushFrontier("· returned from btw ·")
|
||||
var goalCmd tea.Cmd
|
||||
parent, goalCmd = parent.launchGoalContinuationIfReady()
|
||||
return parent, batchCommands(sweepCmd, spinnerCmd, goalCmd)
|
||||
var fileCmd tea.Cmd
|
||||
parent, fileCmd = parent.recoverInvalidatedFileView()
|
||||
return parent, batchCommands(sweepCmd, spinnerCmd, goalCmd, fileCmd)
|
||||
}
|
||||
|
||||
func btwCommandUnavailable(command parsedCommand) bool {
|
||||
@@ -236,9 +262,9 @@ func btwCommandUnavailable(command parsedCommand) bool {
|
||||
// messages have no run ID, so normal BTW routing intentionally leaves them on
|
||||
// the visible side surface; copying the layout fields prevents stale wrapping
|
||||
// after the parent is restored.
|
||||
func (m model) resizeBTWParent(msg tea.WindowSizeMsg) model {
|
||||
func (m model) resizeBTWParent(msg tea.WindowSizeMsg) (model, tea.Cmd) {
|
||||
if !m.btw.active || m.btw.parent == nil {
|
||||
return m
|
||||
return m, nil
|
||||
}
|
||||
parent := *m.btw.parent
|
||||
parent.width = msg.Width
|
||||
@@ -247,8 +273,15 @@ func (m model) resizeBTWParent(msg tea.WindowSizeMsg) model {
|
||||
parent.lineAges = nil
|
||||
parent.lastStreamActivity = parent.now()
|
||||
parent.input.SetWidth(maxInt(20, chatWidth(msg.Width)-14))
|
||||
// The parent's full-file view wrapped at the old width; reload it at the new
|
||||
// one. Its completion has no run ID, so routeBTWParentMessage matches it by
|
||||
// lifetime token and delivers it to the hidden parent.
|
||||
var cmd tea.Cmd
|
||||
if parent.fileView.active && parent.fileView.mode == fileViewFull {
|
||||
parent, cmd = parent.startFileViewLoadCmd(parent.chatColumnWidth())
|
||||
}
|
||||
m.btw.parent = &parent
|
||||
return m
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func btwTitle(parent string) string {
|
||||
@@ -272,6 +305,22 @@ func (m model) routeBTWParentMessage(msg tea.Msg) (model, tea.Cmd, bool) {
|
||||
}
|
||||
return m.routeBTWMessageToParent(msg)
|
||||
}
|
||||
if loaded, ok := msg.(fileViewLoadedMsg); ok {
|
||||
// File-view completions carry no run ID. Deliver the hidden parent's
|
||||
// load (matched by lifetime token) so it does not stay stuck on
|
||||
// "Loading…" while BTW is active; side-owned loads fall through.
|
||||
if m.btw.parent.fileView.active && m.btw.parent.fileView.lifetimeToken == loaded.lifetimeToken {
|
||||
return m.routeBTWMessageToParent(msg)
|
||||
}
|
||||
return m, nil, false
|
||||
}
|
||||
if revalidated, ok := msg.(fileViewRevalidatedMsg); ok {
|
||||
// A revalidation the parent started before /btw is routed the same way.
|
||||
if m.btw.parent.fileView.active && m.btw.parent.fileView.lifetimeToken == revalidated.lifetimeToken {
|
||||
return m.routeBTWMessageToParent(msg)
|
||||
}
|
||||
return m, nil, false
|
||||
}
|
||||
runID, ok := btwMessageRunID(msg)
|
||||
if !ok || runID <= 0 || runID >= m.btw.sideRunIDBase {
|
||||
return m, nil, false
|
||||
@@ -279,6 +328,21 @@ func (m model) routeBTWParentMessage(msg tea.Msg) (model, tea.Cmd, bool) {
|
||||
return m.routeBTWMessageToParent(msg)
|
||||
}
|
||||
|
||||
// refreshHiddenParentFileView reloads the hidden parent's full-file view when a
|
||||
// side-surface mutation advanced the shared path revision. The load completion
|
||||
// carries the parent's lifetime token, so routeBTWParentMessage delivers it
|
||||
// back to the hidden model without waiting for a git sweep or for /btw return.
|
||||
func (m model) refreshHiddenParentFileView() (model, tea.Cmd) {
|
||||
if !m.btw.active || m.btw.parent == nil {
|
||||
return m, nil
|
||||
}
|
||||
parent := *m.btw.parent
|
||||
var cmd tea.Cmd
|
||||
parent, cmd = parent.recoverInvalidatedFileView()
|
||||
m.btw.parent = &parent
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) routeBTWMessageToParent(msg tea.Msg) (model, tea.Cmd, bool) {
|
||||
parentNext, cmd := m.btw.parent.updateModel(msg)
|
||||
parent, ok := parentNext.(model)
|
||||
@@ -296,7 +360,9 @@ func (m model) routeBTWMessageToParent(msg tea.Msg) (model, tea.Cmd, bool) {
|
||||
case agentResponseMsg:
|
||||
m.btw.parentNeedsInput = parent.pendingPermission != nil || parent.pendingAskUser != nil
|
||||
}
|
||||
return m, cmd, true
|
||||
var fileCmd tea.Cmd
|
||||
m, fileCmd = m.recoverInvalidatedFileView()
|
||||
return m, batchCommands(cmd, fileCmd), true
|
||||
}
|
||||
|
||||
func btwMessageRunID(msg tea.Msg) (int, bool) {
|
||||
@@ -327,6 +393,8 @@ func btwMessageRunID(msg tea.Msg) (int, bool) {
|
||||
return typed.runID, true
|
||||
case specialistProgressMsg:
|
||||
return typed.runID, true
|
||||
case unknownScopeMutationMsg:
|
||||
return typed.runID, true
|
||||
case swarmSessionsMsg:
|
||||
return typed.runID, true
|
||||
case permissionRequestMsg:
|
||||
|
||||
@@ -3,6 +3,7 @@ package tui
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -535,3 +536,112 @@ func TestBTWCtrlCDuringRunDoesNotClearDraft(t *testing.T) {
|
||||
t.Fatalf("missing in-flight return guidance: %#v", got.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBTWLeaveRevokesSideFileLoad verifies that leaving BTW revokes the side
|
||||
// surface's file-view lifetime so a still-queued side load cannot run or land
|
||||
// after the view closes, without cancelling the restored parent's own load.
|
||||
func TestBTWLeaveRevokesSideFileLoad(t *testing.T) {
|
||||
resetFileViewCacheForTest()
|
||||
m := newBTWTestModel(t)
|
||||
dir := t.TempDir()
|
||||
m.cwd = dir
|
||||
name := "side_view.go"
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("package side\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Open a full view so the side surface inherits an active file view.
|
||||
m, parentLoadCmd := m.openFileView(name)
|
||||
if parentLoadCmd == nil {
|
||||
t.Fatal("expected a pending parent load command")
|
||||
}
|
||||
parentLiveSeq := m.fileView.liveSeq
|
||||
if parentLiveSeq == nil {
|
||||
t.Fatal("parent load token missing")
|
||||
}
|
||||
parentSeq := parentLiveSeq.Load()
|
||||
|
||||
// Enter BTW: the side surface detaches and schedules its own load.
|
||||
side, sideLoadCmd := m.handleBTWCommand("")
|
||||
if !side.btw.active || side.btw.parent == nil {
|
||||
t.Fatal("expected an active BTW conversation")
|
||||
}
|
||||
if sideLoadCmd == nil {
|
||||
t.Fatal("side surface should schedule its own file load")
|
||||
}
|
||||
sideLiveSeq := side.fileView.liveSeq
|
||||
if sideLiveSeq == nil || sideLiveSeq == parentLiveSeq {
|
||||
t.Fatal("side load must own a detached lifetime token")
|
||||
}
|
||||
sideSeq := sideLiveSeq.Load()
|
||||
|
||||
// Return while the side load is still queued.
|
||||
parent, _ := side.leaveBTW()
|
||||
if sideLiveSeq.Load() == sideSeq {
|
||||
t.Fatal("leaveBTW must revoke the side file-view request")
|
||||
}
|
||||
if parentLiveSeq.Load() != parentSeq || parentLiveSeq == sideLiveSeq {
|
||||
t.Fatal("leaveBTW must not revoke the restored parent's request")
|
||||
}
|
||||
|
||||
// The held side command must now be a superseded no-op.
|
||||
held := sideLoadCmd()
|
||||
loaded, ok := held.(fileViewLoadedMsg)
|
||||
if !ok {
|
||||
t.Fatalf("held side command produced %T, want fileViewLoadedMsg", held)
|
||||
}
|
||||
if !errors.Is(loaded.err, errFileViewSuperseded) {
|
||||
t.Fatalf("queued side load must be superseded after leaving BTW, got %v", loaded.err)
|
||||
}
|
||||
|
||||
// The parent's own load still completes and settles its snapshot.
|
||||
parent = deliverCommandMessages(t, parent, parentLoadCmd)
|
||||
if parent.fileView.loading {
|
||||
t.Fatal("parent load must remain valid after the side revocation")
|
||||
}
|
||||
if !strings.Contains(plainRender(t, parent.renderFileViewFull(80)), "package side") {
|
||||
t.Fatal("restored parent must keep its snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBTWForkOfErroredFileViewShowsLoadingPlaceholder verifies that a side
|
||||
// surface forked from a full view whose last snapshot was a read error does
|
||||
// not render an empty body while its own load is pending. The inherited error
|
||||
// snapshot is invalidated by the fork, so the side must show the loading
|
||||
// placeholder until its load lands, then the side's own error snapshot.
|
||||
func TestBTWForkOfErroredFileViewShowsLoadingPlaceholder(t *testing.T) {
|
||||
resetFileViewCacheForTest()
|
||||
m := newBTWTestModel(t)
|
||||
m.cwd = t.TempDir()
|
||||
|
||||
// A missing file opens straight in full mode and settles on a read error.
|
||||
m, loadCmd := m.openFileView("missing.go")
|
||||
if loadCmd == nil || m.fileView.mode != fileViewFull {
|
||||
t.Fatal("expected a full-view load for a file without recorded edits")
|
||||
}
|
||||
m = deliverCommandMessages(t, m, loadCmd)
|
||||
if !m.fileView.hasError {
|
||||
t.Fatal("parent full view should settle on a read error")
|
||||
}
|
||||
if got := plainRender(t, m.renderFileViewFull(80)); !strings.Contains(got, "Could not read file") {
|
||||
t.Fatalf("parent should show the read error, got %q", got)
|
||||
}
|
||||
|
||||
side, sideLoadCmd := m.handleBTWCommand("")
|
||||
if !side.btw.active || sideLoadCmd == nil {
|
||||
t.Fatal("expected an active BTW surface with its own pending file load")
|
||||
}
|
||||
if got := plainRender(t, side.renderFileViewFull(80)); !strings.Contains(got, fileViewLoadingPlaceholder) {
|
||||
t.Fatalf("pending side load must show the loading placeholder, got %q", got)
|
||||
}
|
||||
if got := plainRender(t, side.btw.parent.renderFileViewFull(80)); !strings.Contains(got, "Could not read file") {
|
||||
t.Fatalf("hidden parent must keep its own error snapshot, got %q", got)
|
||||
}
|
||||
|
||||
side = deliverCommandMessages(t, side, sideLoadCmd)
|
||||
if side.fileView.loading {
|
||||
t.Fatal("side load should settle")
|
||||
}
|
||||
if got := plainRender(t, side.renderFileViewFull(80)); !strings.Contains(got, "Could not read file") {
|
||||
t.Fatalf("settled side load should show its read error, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,15 @@ func (c *staticRenderCache) stats() renderCacheStats {
|
||||
return c.statsData
|
||||
}
|
||||
|
||||
func fileViewCacheStatsForTest() fileViewCacheStats {
|
||||
return defaultFileViewCache.stats()
|
||||
}
|
||||
|
||||
func resetFileViewCacheForTest() {
|
||||
defaultFileViewCache.clear()
|
||||
defaultFileViewCache.resetStats()
|
||||
}
|
||||
|
||||
func renderSelectableList(options selectableListOptions) string {
|
||||
if len(options.Items) == 0 {
|
||||
return ""
|
||||
|
||||
+1356
-70
File diff suppressed because it is too large.
Load diff
+4237
-18
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,55 @@
|
||||
//go:build !windows
|
||||
|
||||
package tui
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFileView_SpecialTargetNonRegularFileRejected(t *testing.T) {
|
||||
resetFileViewCacheForTest()
|
||||
dir := t.TempDir()
|
||||
pipePath := filepath.Join(dir, "test.pipe")
|
||||
|
||||
// Create a FIFO if supported on the platform
|
||||
if err := syscall.Mkfifo(pipePath, 0o600); err != nil {
|
||||
t.Skipf("mkfifo not supported on this environment: %v", err)
|
||||
}
|
||||
|
||||
res := readFileViewBounded(pipePath, 100, 1000, 10000)
|
||||
if res.err == nil {
|
||||
t.Fatal("expected error reading FIFO non-regular file, got nil")
|
||||
}
|
||||
if !strings.Contains(res.err.Error(), "non-regular") {
|
||||
t.Fatalf("expected non-regular error message, got: %v", res.err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileView_FIFONeverBlocksOpen(t *testing.T) {
|
||||
resetFileViewCacheForTest()
|
||||
dir := t.TempDir()
|
||||
pipePath := filepath.Join(dir, "hang_test.pipe")
|
||||
|
||||
if err := syscall.Mkfifo(pipePath, 0o600); err != nil {
|
||||
t.Skipf("mkfifo not supported on this environment: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan fileViewReadResult, 1)
|
||||
go func() {
|
||||
res := readFileViewBounded(pipePath, 100, 1000, 10000)
|
||||
done <- res
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-done:
|
||||
if res.err == nil || !strings.Contains(res.err.Error(), "non-regular") {
|
||||
t.Fatalf("expected non-regular error, got: %v", res.err)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("readFileViewBounded blocked on open(2) for FIFO without writer (TOCTOU / missing O_NONBLOCK)")
|
||||
}
|
||||
}
|
||||
@@ -152,12 +152,12 @@ func TestTouchedFilesMergesGitSweep(t *testing.T) {
|
||||
func TestOpenFileViewGitOnlyFallsBackToFull(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m.gitTouched = []gitSweepFile{{path: "kanban/board.tsx", created: true}}
|
||||
m = m.openFileView("kanban/board.tsx")
|
||||
m, _ = m.openFileView("kanban/board.tsx")
|
||||
if m.fileView.mode != fileViewFull {
|
||||
t.Fatal("git-only file should open in full mode")
|
||||
}
|
||||
m = m.exitFileView()
|
||||
m = m.openFileView("web/app.js")
|
||||
m, _ = m.openFileView("web/app.js")
|
||||
if m.fileView.mode != fileViewDiff {
|
||||
t.Fatal("a file with edit cards still opens in diff mode")
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
@@ -357,8 +359,13 @@ func (m *model) setSelectedFile(path string) {
|
||||
|
||||
// selectFile marks path as the selected file and scrolls the transcript so its
|
||||
// most recent edit card is in view; the card tint comes from the renderers
|
||||
// reading selectedFile (rowTouchesSelectedFile).
|
||||
func (m model) selectFile(path string) model {
|
||||
// reading selectedFile (rowTouchesSelectedFile). A second activation of the
|
||||
// same path drills into the file view and returns any load command.
|
||||
func (m model) selectFile(path string) (model, tea.Cmd) {
|
||||
if m.selectedFile == path {
|
||||
m.runDetailsOpen = false
|
||||
return m.openFileView(path)
|
||||
}
|
||||
rowIndex := m.lastRowIndexForFile(path)
|
||||
m.setSelectedFile(path)
|
||||
if offset, ok := m.scrollOffsetForTranscriptRow(rowIndex); ok {
|
||||
@@ -367,7 +374,46 @@ func (m model) selectFile(path string) model {
|
||||
m.chatBodyLines = 0
|
||||
}
|
||||
}
|
||||
return m
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) runDetailsInnerWidth() int {
|
||||
overlayWidth := minInt(72, maxInt(40, m.width-8))
|
||||
overlayWidth = minInt(overlayWidth, m.width)
|
||||
return maxInt(12, overlayWidth-4)
|
||||
}
|
||||
|
||||
func (m model) runDetailsFileAtMouse(msg tea.MouseMsg) (string, bool) {
|
||||
if !m.runDetailsVisible() {
|
||||
return "", false
|
||||
}
|
||||
width := chatWidth(m.width)
|
||||
overlay := m.runDetailsOverlay(width)
|
||||
hit, ok := m.overlayMouseHit(msg, overlay, width)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
inner := m.runDetailsInnerWidth()
|
||||
layout := m.runDetailsLayout(inner)
|
||||
if layout.fileStart < 0 {
|
||||
return "", false
|
||||
}
|
||||
// styledBlockFillTitle contributes a 1-line top border with title before the content rows.
|
||||
const topBorderHeight = 1
|
||||
y := hit.y - topBorderHeight
|
||||
var match fileHit
|
||||
found := false
|
||||
for _, h := range layout.fileHits {
|
||||
if y == layout.fileStart+h.lineOffset {
|
||||
match = h
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
return match.path, true
|
||||
}
|
||||
|
||||
// scrollOffsetForTranscriptRow computes the chatScrollOffset that places the
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/dictation"
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
@@ -296,6 +298,24 @@ func TestSidebarFileLinesOverflowExcludesLiveRow(t *testing.T) {
|
||||
|
||||
// TestSidebarHasContentForLiveWrite: the sidebar counts an in-flight first
|
||||
// write as content, so the FILES pulse is visible before any result row lands.
|
||||
func TestSidebarFileHitsDistinguishSuffixPaths(t *testing.T) {
|
||||
m := sidebarTestModel()
|
||||
m.transcript = append(m.transcript,
|
||||
transcriptRow{kind: rowToolResult, tool: "edit_file", id: "a", status: tools.StatusOK, changedFiles: []string{"a.go"}},
|
||||
transcriptRow{kind: rowToolResult, tool: "edit_file", id: "b", status: tools.StatusOK, changedFiles: []string{"dir/a.go"}},
|
||||
)
|
||||
_, hits := m.sidebarFileLines(80)
|
||||
if len(hits) < 2 {
|
||||
t.Fatalf("want two fileHit rows, got %#v", hits)
|
||||
}
|
||||
if hits[0].path == hits[1].path {
|
||||
t.Fatal("fileHit paths must stay distinct")
|
||||
}
|
||||
if hits[0].lineOffset == hits[1].lineOffset {
|
||||
t.Fatal("fileHit rows must not share a line offset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidebarHasContentForLiveWrite(t *testing.T) {
|
||||
m := sidebarTestModel()
|
||||
m.plan.steps = nil // drop the helper's seeded plan: no agents/plan/files now
|
||||
@@ -309,3 +329,235 @@ func TestSidebarHasContentForLiveWrite(t *testing.T) {
|
||||
t.Fatal("a live in-flight write must count as sidebar content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDetailsFileMouseSelectionEndToEnd(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
m.width = 80
|
||||
m.height = 24
|
||||
m.altScreen = true
|
||||
m.runDetailsOpen = true
|
||||
|
||||
overlay := m.runDetailsOverlay(m.width)
|
||||
if overlay == "" {
|
||||
t.Fatal("expected non-empty runDetailsOverlay")
|
||||
}
|
||||
|
||||
inner := m.runDetailsInnerWidth()
|
||||
layout := m.runDetailsLayout(inner)
|
||||
if len(layout.fileHits) == 0 || layout.fileStart < 0 {
|
||||
t.Fatalf("expected file hits in layout, got: %+v", layout)
|
||||
}
|
||||
|
||||
targetHit := layout.fileHits[0] // "internal/tui/sidebar.go"
|
||||
overlayLines := viewLines(overlay)
|
||||
left, trimmedLines, overlayWidth := normalizeOverlayBlock(overlayLines, m.width)
|
||||
if overlayWidth <= 0 || len(trimmedLines) == 0 {
|
||||
t.Fatalf("invalid overlay block normalization: left=%d, width=%d", left, overlayWidth)
|
||||
}
|
||||
|
||||
rect := m.overlayMouseRect(len(trimmedLines), m.width)
|
||||
// Click on the target file row: top border is row 0 of the overlay, content starts at row 1
|
||||
clickY := rect.y + 1 + layout.fileStart + targetHit.lineOffset
|
||||
clickX := left + 4 // inside the border and padding
|
||||
|
||||
clickMsg := testMouseClick(tea.MouseLeft, clickX, clickY)
|
||||
|
||||
path, ok := m.runDetailsFileAtMouse(clickMsg)
|
||||
if !ok || path != targetHit.path {
|
||||
t.Fatalf("runDetailsFileAtMouse failed: ok=%v, got=%q, want=%q", ok, path, targetHit.path)
|
||||
}
|
||||
|
||||
// Route the click through m.Update to verify end-to-end selection
|
||||
updated, _ := m.Update(clickMsg)
|
||||
m = updated.(model)
|
||||
|
||||
if m.selectedFile != targetHit.path {
|
||||
t.Fatalf("expected selectedFile %q after mouse click, got %q", targetHit.path, m.selectedFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDetailsFileMouseBlockedBehindHigherPriorityOverlays(t *testing.T) {
|
||||
baseModel := func(t *testing.T) (model, tea.MouseClickMsg, string) {
|
||||
t.Helper()
|
||||
m := filesPanelTestModel()
|
||||
m.width = 80
|
||||
m.height = 24
|
||||
m.altScreen = true
|
||||
m.runDetailsOpen = true
|
||||
|
||||
overlay := m.runDetailsOverlay(m.width)
|
||||
if overlay == "" {
|
||||
t.Fatal("expected non-empty runDetailsOverlay")
|
||||
}
|
||||
inner := m.runDetailsInnerWidth()
|
||||
layout := m.runDetailsLayout(inner)
|
||||
if len(layout.fileHits) == 0 || layout.fileStart < 0 {
|
||||
t.Fatalf("expected file hits in layout, got: %+v", layout)
|
||||
}
|
||||
targetHit := layout.fileHits[0]
|
||||
overlayLines := viewLines(overlay)
|
||||
left, trimmedLines, overlayWidth := normalizeOverlayBlock(overlayLines, m.width)
|
||||
if overlayWidth <= 0 || len(trimmedLines) == 0 {
|
||||
t.Fatalf("invalid overlay block normalization: left=%d, width=%d", left, overlayWidth)
|
||||
}
|
||||
rect := m.overlayMouseRect(len(trimmedLines), m.width)
|
||||
clickY := rect.y + 1 + layout.fileStart + targetHit.lineOffset
|
||||
clickX := left + 4
|
||||
clickMsg := testMouseClick(tea.MouseLeft, clickX, clickY)
|
||||
if path, ok := m.runDetailsFileAtMouse(clickMsg); !ok || path != targetHit.path {
|
||||
t.Fatalf("precondition failed: visible Run details click should hit %q, got ok=%v path=%q", targetHit.path, ok, path)
|
||||
}
|
||||
return m, clickMsg, targetHit.path
|
||||
}
|
||||
|
||||
t.Run("speech_api_key_prompt_blocks_hidden_run_details_click", func(t *testing.T) {
|
||||
m, clickMsg, wantPath := baseModel(t)
|
||||
m.dictation.cfg = config.STTConfig{Provider: config.STTProviderGroq, Model: "whisper-large-v3-turbo"}
|
||||
m.dictation.saveKey = func(string, string) error { return nil }
|
||||
m.dictation.phase = dictTranscribing
|
||||
|
||||
// Asynchronous dictation auth error opens the speech API-key prompt while Run details stays open.
|
||||
updated, _ := m.Update(dictationTranscribedMsg{
|
||||
sessionID: m.dictation.sessionID,
|
||||
err: &dictation.AuthError{Provider: "groq", Message: "auth error: your API key is missing or invalid"},
|
||||
})
|
||||
m = updated.(model)
|
||||
if !m.runDetailsOpen || m.sttKeyPrompt == nil {
|
||||
t.Fatalf("expected runDetailsOpen=true and sttKeyPrompt!=nil, got runDetailsOpen=%v sttKeyPrompt=%v", m.runDetailsOpen, m.sttKeyPrompt)
|
||||
}
|
||||
if m.runDetailsVisible() {
|
||||
t.Fatal("Run details must not report visible while the speech API-key prompt owns the viewport")
|
||||
}
|
||||
if path, ok := m.runDetailsFileAtMouse(clickMsg); ok || path != "" {
|
||||
t.Fatalf("runDetailsFileAtMouse must reject clicks behind speech prompt, got ok=%v path=%q", ok, path)
|
||||
}
|
||||
|
||||
// First and second clicks at the shared coordinate must neither select the file nor open fileView.
|
||||
for i := 0; i < 2; i++ {
|
||||
var cmd tea.Cmd
|
||||
updated, cmd = m.Update(clickMsg)
|
||||
m = updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatalf("click %d behind speech prompt should not return a command", i+1)
|
||||
}
|
||||
if m.selectedFile != "" || m.fileView.active {
|
||||
t.Fatalf("click %d behind speech prompt leaked to Run details: selectedFile=%q fileView.active=%v", i+1, m.selectedFile, m.fileView.active)
|
||||
}
|
||||
}
|
||||
|
||||
// Dismissing the speech prompt restores Run details to the foreground so the click works again.
|
||||
updated, _ = m.Update(testKey(tea.KeyEsc))
|
||||
m = updated.(model)
|
||||
if m.sttKeyPrompt != nil || !m.runDetailsVisible() {
|
||||
t.Fatal("Run details should be visible again after dismissing the speech prompt")
|
||||
}
|
||||
updated, _ = m.Update(clickMsg)
|
||||
m = updated.(model)
|
||||
if m.selectedFile != wantPath {
|
||||
t.Fatalf("expected selectedFile %q after speech prompt closed, got %q", wantPath, m.selectedFile)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leader_help_overlay_blocks_hidden_run_details_click", func(t *testing.T) {
|
||||
m, clickMsg, wantPath := baseModel(t)
|
||||
|
||||
// Ctrl+X then ? opens the leader help overlay in front of Run details.
|
||||
updated, _ := m.Update(testKeyCtrl('x'))
|
||||
m = updated.(model)
|
||||
updated, _ = m.Update(tea.KeyPressMsg{Code: '?', Text: "?"})
|
||||
m = updated.(model)
|
||||
if !m.runDetailsOpen || !m.leaderHelpOverlay {
|
||||
t.Fatalf("expected runDetailsOpen=true and leaderHelpOverlay=true, got runDetailsOpen=%v leaderHelpOverlay=%v", m.runDetailsOpen, m.leaderHelpOverlay)
|
||||
}
|
||||
if m.runDetailsVisible() {
|
||||
t.Fatal("Run details must not report visible while leader help owns the viewport")
|
||||
}
|
||||
if path, ok := m.runDetailsFileAtMouse(clickMsg); ok || path != "" {
|
||||
t.Fatalf("runDetailsFileAtMouse must reject clicks behind leader help, got ok=%v path=%q", ok, path)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
var cmd tea.Cmd
|
||||
updated, cmd = m.Update(clickMsg)
|
||||
m = updated.(model)
|
||||
if cmd != nil {
|
||||
t.Fatalf("click %d behind leader help should not return a command", i+1)
|
||||
}
|
||||
if m.selectedFile != "" || m.fileView.active {
|
||||
t.Fatalf("click %d behind leader help leaked to Run details: selectedFile=%q fileView.active=%v", i+1, m.selectedFile, m.fileView.active)
|
||||
}
|
||||
}
|
||||
|
||||
// Dismissing leader help with Esc restores Run details to the foreground.
|
||||
updated, _ = m.Update(testKey(tea.KeyEsc))
|
||||
m = updated.(model)
|
||||
if m.leaderHelpOverlay || !m.runDetailsVisible() {
|
||||
t.Fatal("Run details should be visible again after dismissing leader help")
|
||||
}
|
||||
updated, _ = m.Update(clickMsg)
|
||||
m = updated.(model)
|
||||
if m.selectedFile != wantPath {
|
||||
t.Fatalf("expected selectedFile %q after leader help closed, got %q", wantPath, m.selectedFile)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRunDetailsEnterBlockedWhenRunDetailsHidden pins the keyboard twin of the
|
||||
// mouse gate above: runDetailsOpen can outlive the visible overlay, and Enter
|
||||
// must not drill into a FILES row the user can no longer see.
|
||||
func TestRunDetailsEnterBlockedWhenRunDetailsHidden(t *testing.T) {
|
||||
// setup returns a model with Run details visible and a FILES row selected
|
||||
// that Enter would open (verified on a copy), so a later denial can only
|
||||
// come from the visibility gate.
|
||||
setup := func(t *testing.T, m model) (model, string) {
|
||||
t.Helper()
|
||||
m.transcript = append(m.transcript, transcriptRow{kind: rowToolResult, tool: "write_file", id: "f1",
|
||||
status: tools.StatusOK, changedFiles: []string{"web/app.js"}})
|
||||
m.runDetailsOpen = true
|
||||
layout := m.runDetailsLayout(m.runDetailsInnerWidth())
|
||||
if !m.runDetailsVisible() || len(layout.fileHits) == 0 {
|
||||
t.Fatalf("precondition: Run details must be visible with FILES hits, visible=%v hits=%d", m.runDetailsVisible(), len(layout.fileHits))
|
||||
}
|
||||
path := layout.fileHits[0].path
|
||||
m, _ = m.selectFile(path)
|
||||
if m.selectedFile != path || m.fileView.active {
|
||||
t.Fatalf("precondition: first activation must only select %q, got selected=%q fileView=%v", path, m.selectedFile, m.fileView.active)
|
||||
}
|
||||
control, _ := m.Update(testKey(tea.KeyEnter))
|
||||
if fv := control.(model).fileView; !fv.active || fv.path != path {
|
||||
t.Fatalf("precondition: Enter on visible Run details must open %q, got active=%v path=%q", path, fv.active, fv.path)
|
||||
}
|
||||
return m, path
|
||||
}
|
||||
assertEnterIgnored := func(t *testing.T, m model, path string) {
|
||||
t.Helper()
|
||||
if !m.runDetailsOpen || m.runDetailsVisible() {
|
||||
t.Fatalf("expected runDetailsOpen=true with Run details hidden, got open=%v visible=%v", m.runDetailsOpen, m.runDetailsVisible())
|
||||
}
|
||||
updated, cmd := m.Update(testKey(tea.KeyEnter))
|
||||
m = updated.(model)
|
||||
if m.fileView.active || cmd != nil {
|
||||
t.Fatalf("Enter behind hidden Run details opened %q: fileView.active=%v path=%q cmd=%v", path, m.fileView.active, m.fileView.path, cmd != nil)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("specialist_card_click_enters_subchat", func(t *testing.T) {
|
||||
m, path := setup(t, specialistClickTestModel(t, "child-sess-1"))
|
||||
x, y, ok := specialistCardClickPoint(m, 2)
|
||||
if !ok {
|
||||
t.Fatal("expected a specialist card selectable line in the layout")
|
||||
}
|
||||
updated, _ := m.Update(testMouseClick(tea.MouseLeft, x, y))
|
||||
m = updated.(model)
|
||||
if !m.subchat.active {
|
||||
t.Fatal("clicking the specialist card beside Run details must enter the subchat")
|
||||
}
|
||||
assertEnterIgnored(t, m, path)
|
||||
})
|
||||
|
||||
t.Run("detailed_transcript", func(t *testing.T) {
|
||||
m, path := setup(t, filesPanelTestModel())
|
||||
m.transcriptDetailed = true
|
||||
assertEnterIgnored(t, m, path)
|
||||
})
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
|
||||
t.Fatal("precondition: settled cache should be populated")
|
||||
}
|
||||
|
||||
m = m.selectFile("internal/tui/sidebar.go")
|
||||
m, _ = m.selectFile("internal/tui/sidebar.go")
|
||||
if m.altScreenSettledWidth != 0 {
|
||||
t.Fatal("selecting a file touched by a settled row must invalidate the cache")
|
||||
}
|
||||
|
||||
+227
-23
@@ -779,6 +779,13 @@ type specialistCompleteMsg struct {
|
||||
errorMsg string
|
||||
}
|
||||
|
||||
// unknownScopeMutationMsg carries the completion of a suppressed tool result
|
||||
// (e.g. TaskOutput) that may have mutated workspace files without reporting the
|
||||
// affected paths. It triggers the conservative file-view refresh.
|
||||
type unknownScopeMutationMsg struct {
|
||||
runID int
|
||||
}
|
||||
|
||||
// swarmSessionsMsg carries swarm task_id -> member session_id pairs (from
|
||||
// swarm_collect's Meta) so the AGENTS sidebar rows can drill into a member's
|
||||
// session like a specialist card.
|
||||
@@ -1439,13 +1446,18 @@ func batchCommands(cmds ...tea.Cmd) tea.Cmd {
|
||||
}
|
||||
|
||||
func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var resizeCmd tea.Cmd
|
||||
if size, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m = m.resizeBTWParent(size)
|
||||
m, resizeCmd = m.resizeBTWParent(size)
|
||||
}
|
||||
if next, cmd, routed := m.routeBTWParentMessage(msg); routed {
|
||||
return next, cmd
|
||||
return next, batchCommands(cmd, resizeCmd)
|
||||
}
|
||||
switch msg := msg.(type) {
|
||||
case fileViewLoadedMsg:
|
||||
return m.handleFileViewLoaded(msg)
|
||||
case fileViewRevalidatedMsg:
|
||||
return m.handleFileViewRevalidated(msg)
|
||||
case uv.CellSizeEvent:
|
||||
if msg.Width > 0 && msg.Height > 0 {
|
||||
m.petCellPixelWidth = msg.Width
|
||||
@@ -1508,6 +1520,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.hasDarkBg = msg.IsDark()
|
||||
if m.themeMode != themeSystem {
|
||||
applyTheme(m.themeMode, m.hasDarkBg)
|
||||
if m.fileView.active && m.fileView.mode == fileViewFull {
|
||||
return m.startFileViewLoadCmd(m.chatColumnWidth())
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case tea.MouseMsg:
|
||||
@@ -1763,6 +1778,19 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case m.runDetailsOpen:
|
||||
if keyIs(msg, tea.KeyEsc) || m.keyMatch(m.keyBindings.toggleSidebar, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'b') }) {
|
||||
m.runDetailsOpen = false
|
||||
return m, nil
|
||||
}
|
||||
// Enter drills into the selected FILES row only while Run details is
|
||||
// the overlay on screen, the same gate mouse picks use. The flag can
|
||||
// outlive the visible overlay (e.g. a click entered a subchat).
|
||||
if keyIs(msg, tea.KeyEnter) && m.selectedFile != "" && m.runDetailsVisible() {
|
||||
layout := m.runDetailsLayout(m.runDetailsInnerWidth())
|
||||
for _, h := range layout.fileHits {
|
||||
if h.path == m.selectedFile {
|
||||
return m.selectFile(m.selectedFile)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
case m.keyMatch(m.keyBindings.toggleDetailed, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'o') }):
|
||||
@@ -1772,9 +1800,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// (so mid-sentence typing is never hijacked) and no modal is up (so a
|
||||
// permission prompt / ask-user / wizard keeps its own key handling).
|
||||
if keyText(msg) == "f" {
|
||||
return m.setFileViewMode(fileViewFull), nil
|
||||
return m.setFileViewMode(fileViewFull)
|
||||
}
|
||||
return m.setFileViewMode(fileViewDiff), nil
|
||||
return m.setFileViewMode(fileViewDiff)
|
||||
case m.keyMatch(m.keyBindings.toggleMouse, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'e') }) && canFireComposerGatedToggle(m.keyBindings.toggleMouse, defaultToggleMouseChord, m.composerValue() == ""):
|
||||
// Release/recapture the mouse so the user can drag-select and copy text
|
||||
// natively (mouse capture otherwise intercepts terminal selection). The
|
||||
@@ -2561,7 +2589,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
// A resumed/idle session may already hold agents; keep their short lifecycle
|
||||
// fade alive. No-op when the loop is already running or nothing animates.
|
||||
return m, m.ensureSpinnerTick()
|
||||
if m.fileView.active && m.fileView.mode == fileViewFull {
|
||||
var cmd tea.Cmd
|
||||
m, cmd = m.startFileViewLoadCmd(m.chatColumnWidth())
|
||||
return m, batchCommands(m.ensureSpinnerTick(), cmd, resizeCmd)
|
||||
}
|
||||
return m, batchCommands(m.ensureSpinnerTick(), resizeCmd)
|
||||
case permissionRequestMsg:
|
||||
// The agent goroutine that raised this request is BLOCKED waiting on the
|
||||
// decision callback, so every branch below must resolve it exactly once —
|
||||
@@ -2691,6 +2724,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.exiting && len(m.flushRunIDs) == 0 {
|
||||
return m.quit()
|
||||
}
|
||||
// The cancelled run kept going until it finished, and agentRowMsg
|
||||
// ignored its late tool results because the run is no longer active.
|
||||
// A write that completed in that window reported nothing, so give an
|
||||
// open file view the conservative unknown-scope refresh.
|
||||
return m.invalidateFileViewForUnknownMutation()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -2839,6 +2877,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// complete once the turn settles.
|
||||
var sweepCmd tea.Cmd
|
||||
m, sweepCmd = m.maybeGitSweep()
|
||||
// The sweep is a no-op outside a git workspace, so an open full-file view
|
||||
// still needs the conservative invalidation to pick up mutations whose
|
||||
// paths were never reported (e.g. a hidden TaskOutput success).
|
||||
var fileRefreshCmd tea.Cmd
|
||||
m, fileRefreshCmd = m.invalidateFileViewForUnknownMutation()
|
||||
// If this run was a loop iteration, advance that loop (schedule its next wake
|
||||
// or stop it). Done before launchQueuedMessageIfReady so a user's queued prompt
|
||||
// still wins the immediate re-launch; the loop fires on the next idle tick.
|
||||
@@ -2870,7 +2913,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.goalAware && !hadQueuedMessage && peerCmd == nil && next.pendingPermission == nil && msg.specReview == nil {
|
||||
next, goalCmd = next.launchGoalContinuationIfReady()
|
||||
}
|
||||
return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, queuedCmd, peerCmd, peerApprovalCmd, loopTickCmd, goalCmd)
|
||||
return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, fileRefreshCmd, queuedCmd, peerCmd, peerApprovalCmd, loopTickCmd, goalCmd)
|
||||
case sessionTitleGeneratedMsg:
|
||||
return m.handleSessionTitleGenerated(msg)
|
||||
case recapIdleMsg:
|
||||
@@ -2898,6 +2941,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.lastCompactResult = &msg.result
|
||||
m = m.setCompactStatusRow(m.compactText(true))
|
||||
if msg.hasSessionSnapshot {
|
||||
return m.refreshFileViewMarkers()
|
||||
}
|
||||
return m, nil
|
||||
case planUpdateMsg:
|
||||
if msg.runID != m.activeRunID {
|
||||
@@ -2976,7 +3022,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.transcript = appendTranscriptRow(m.transcript, cardRow)
|
||||
}
|
||||
return m, nil
|
||||
// A specialist (Task) works in a separate workspace-visible process and
|
||||
// its successful result is suppressed from the transcript, so this is the
|
||||
// only boundary that sees the mutation. Conservatively invalidate the open
|
||||
// snapshot: the child may have changed files without a path report.
|
||||
var refresh tea.Cmd
|
||||
m, refresh = m.invalidateFileViewForUnknownMutation()
|
||||
return m, refresh
|
||||
case specialistProgressMsg:
|
||||
if msg.runID != m.activeRunID {
|
||||
return m, nil
|
||||
@@ -3031,10 +3083,65 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// A finished command tool may have mutated files git can see but no
|
||||
// changedFiles reports (npm create, heredoc writes, subagent edits) —
|
||||
// re-sweep so the FILES sidebar picks them up mid-turn.
|
||||
if msg.row.kind == rowToolResult && isPlanCommandTool(msg.row.tool) {
|
||||
var sweep tea.Cmd
|
||||
m, sweep = m.maybeGitSweep()
|
||||
return m, sweep
|
||||
if msg.row.kind == rowToolResult {
|
||||
var loadCmds []tea.Cmd
|
||||
invalidated := false
|
||||
selfNeedsRefresh := false
|
||||
for _, p := range msg.row.changedFiles {
|
||||
invalidated = true
|
||||
target := p
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(m.cwd, target)
|
||||
}
|
||||
rev := defaultFileViewCache.invalidatePath(target)
|
||||
if m.fileView.active && (p == m.fileView.path || target == m.fileView.path) {
|
||||
m.fileView.requiredSourceRev = rev
|
||||
if m.fileView.mode == fileViewFull {
|
||||
selfNeedsRefresh = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// A tool that can mutate files outside exact ChangedFiles (command
|
||||
// tools, terminal sessions, swarm status/collect, or results with
|
||||
// aggregated directory summaries) must also run the conservative
|
||||
// unknown-scope refresh even when exact paths were reported.
|
||||
unknownScope := isPlanCommandTool(msg.row.tool) ||
|
||||
toolResultMayMutateUnknownScope(msg.row.tool) ||
|
||||
len(msg.row.changeSummaries) > 0
|
||||
if unknownScope {
|
||||
var sweep tea.Cmd
|
||||
m, sweep = m.maybeGitSweep()
|
||||
if sweep != nil {
|
||||
loadCmds = append(loadCmds, sweep)
|
||||
}
|
||||
var refresh tea.Cmd
|
||||
m, refresh = m.invalidateFileViewForUnknownMutation()
|
||||
if refresh != nil {
|
||||
loadCmds = append(loadCmds, refresh)
|
||||
}
|
||||
selfNeedsRefresh = false
|
||||
}
|
||||
if selfNeedsRefresh {
|
||||
var cmd tea.Cmd
|
||||
m, cmd = m.startFileViewRefreshCmd(m.chatColumnWidth())
|
||||
if cmd != nil {
|
||||
loadCmds = append(loadCmds, cmd)
|
||||
}
|
||||
}
|
||||
// The handler above only reloads the surface that received the result.
|
||||
// A side mutation must also wake the hidden parent when that parent is
|
||||
// showing the same path; the reverse direction is recovered after the
|
||||
// parent message is routed. Neither wait depends on a git sweep.
|
||||
if invalidated && !unknownScope {
|
||||
var peerCmd tea.Cmd
|
||||
m, peerCmd = m.refreshHiddenParentFileView()
|
||||
if peerCmd != nil {
|
||||
loadCmds = append(loadCmds, peerCmd)
|
||||
}
|
||||
}
|
||||
if len(loadCmds) > 0 {
|
||||
return m, batchCommands(loadCmds...)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case swarmSessionsMsg:
|
||||
@@ -3050,6 +3157,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case unknownScopeMutationMsg:
|
||||
// A suppressed mutating result (TaskOutput) completed. Its success row is
|
||||
// hidden, so trigger the conservative refresh on the owning surface.
|
||||
if msg.runID != m.activeRunID {
|
||||
return m, nil
|
||||
}
|
||||
return m.invalidateFileViewForUnknownMutation()
|
||||
case doctorCommandResultMsg:
|
||||
if msg.id == 0 || msg.id == m.doctorCommandSeq {
|
||||
m.doctorInFlight = false
|
||||
@@ -3067,7 +3181,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.prState = msg.state
|
||||
return m, nil
|
||||
case gitSweepMsg:
|
||||
return m.handleGitSweepMsg(msg), nil
|
||||
m = m.handleGitSweepMsg(msg)
|
||||
// The swept list is cumulative since the baseline, so it says nothing
|
||||
// about what changed since the open snapshot, and each live sweep starts
|
||||
// beside tool-result, turn-end, or BTW-return handling that already
|
||||
// reloads or rechecks the open view. Revalidate the open view's content
|
||||
// rather than force a second reload.
|
||||
return m.revalidateFileView()
|
||||
case prWatcherStartedMsg:
|
||||
if msg.stop == nil {
|
||||
return m, nil
|
||||
@@ -3079,7 +3199,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
case bashResultMsg:
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: msg.output})
|
||||
return m, nil
|
||||
// invalidateFileViewForUnknownMutation schedules startFileViewRefreshCmd when fileView is active
|
||||
return m.invalidateFileViewForUnknownMutation()
|
||||
case providerModelsDiscoveredMsg:
|
||||
return m.applyProviderModelsDiscovered(msg), nil
|
||||
case setupModelsDiscoveredMsg:
|
||||
@@ -3715,6 +3836,16 @@ func (m model) scrollChat(delta int) model {
|
||||
if !m.altScreen || delta == 0 {
|
||||
return m
|
||||
}
|
||||
// A pending full-file reload renders a one-line placeholder whose geometry
|
||||
// cannot represent the reader's position. Apply the delta to the pending
|
||||
// intent against the last completed body instead, so a Page Down, arrow, or
|
||||
// wheel during the reload is not discarded by the placeholder's zero height.
|
||||
if m.fileView.active && m.fileView.mode == fileViewFull && m.fileView.loading {
|
||||
maxOffset := maxInt(0, m.fileView.completedBodyLines-m.chatViewportHeight())
|
||||
m.fileView.preservedScrollOffset = clampInt(m.fileView.preservedScrollOffset+delta, 0, maxOffset)
|
||||
m.chatScrollOffset = m.fileView.preservedScrollOffset
|
||||
return m
|
||||
}
|
||||
viewport, ok := m.chatTranscriptViewport()
|
||||
if !ok {
|
||||
return m
|
||||
@@ -3726,6 +3857,28 @@ func (m model) scrollChat(delta int) model {
|
||||
return m
|
||||
}
|
||||
|
||||
// chatViewportHeight returns the height of the transcript body viewport, or 0
|
||||
// when it cannot be measured. It lets pending-scroll math run against the last
|
||||
// completed body even while the visible body is the loading placeholder.
|
||||
func (m model) chatViewportHeight() int {
|
||||
viewport, ok := m.chatTranscriptViewport()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return viewport.height
|
||||
}
|
||||
|
||||
// resetChatScrollToBottom snaps the viewport to the tail for a real submission
|
||||
// and, if a full-file reload is pending, records the same intent so completion
|
||||
// cannot restore the reader's pre-submission position.
|
||||
func (m model) resetChatScrollToBottom() model {
|
||||
m.chatScrollOffset = 0
|
||||
if m.fileView.loading {
|
||||
m.fileView.preservedScrollOffset = 0
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) chatScrollMetrics() (int, int) {
|
||||
viewport, ok := m.chatTranscriptViewport()
|
||||
if !ok {
|
||||
@@ -3760,6 +3913,16 @@ func (m model) chatTranscriptViewport() (transcriptViewport, bool) {
|
||||
// so the absolute view holds; at the bottom (offset 0) it follows normally. Only the
|
||||
// scrolled-up path renders the body, so the common case stays cheap.
|
||||
func (m model) syncChatScroll() model {
|
||||
// A pending full-file reload temporarily renders a one-line loading
|
||||
// placeholder. Measuring it would clamp the reader's offset to 0 and lose
|
||||
// their place, so hold the pending intent — which scroll actions and prompt
|
||||
// submission keep up to date — until handleFileViewLoaded reconciles it
|
||||
// against the real body.
|
||||
if m.altScreen && m.fileView.active && m.fileView.mode == fileViewFull &&
|
||||
m.fileView.loading && m.fileView.preservedScrollOffset > 0 {
|
||||
m.chatScrollOffset = m.fileView.preservedScrollOffset
|
||||
return m
|
||||
}
|
||||
if !m.altScreen || m.chatScrollOffset <= 0 {
|
||||
// At the bottom (or inline mode): follow the tail; reset the pin baseline.
|
||||
m.chatBodyLines = 0
|
||||
@@ -4626,6 +4789,9 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||||
if text != "" {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
}
|
||||
if text == "" {
|
||||
m, cmd = m.refreshFileViewMarkers()
|
||||
}
|
||||
case pickerSkill:
|
||||
// Fill the composer with "/name " so the user adds their request before
|
||||
// submitting (a bare second Enter runs it without one); names the slash
|
||||
@@ -4651,10 +4817,21 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||||
// local preview and never changes the active palette.
|
||||
text := ""
|
||||
m, text = m.handleThemeCommand(item.Value)
|
||||
var loadCmd tea.Cmd
|
||||
if m.fileView.active && m.fileView.mode == fileViewFull {
|
||||
m, loadCmd = m.startFileViewLoadCmd(m.chatColumnWidth())
|
||||
}
|
||||
if validThemeMode(item.Value) && !strings.Contains(text, "could not save theme preference") {
|
||||
return m.showTransientNotice(m.themeAppliedNotice(), transientNoticeSuccess)
|
||||
next, noticeCmd := m.showTransientNotice(m.themeAppliedNotice(), transientNoticeSuccess)
|
||||
if loadCmd != nil {
|
||||
return next, tea.Batch(noticeCmd, loadCmd)
|
||||
}
|
||||
return next, noticeCmd
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
if loadCmd != nil {
|
||||
return m, loadCmd
|
||||
}
|
||||
case pickerNotify:
|
||||
// The picker item's Value is "<mode> <focus>"; reusing the text handler
|
||||
// keeps validation, persistence, and the user-facing message in one
|
||||
@@ -4730,9 +4907,10 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
m.clearSuggestions()
|
||||
// Snap the viewport back to the bottom for a real submission, but not for an
|
||||
// empty Enter (a no-op) — that would yank the user away from wherever they
|
||||
// had scrolled without anything actually being submitted.
|
||||
// had scrolled without anything actually being submitted. A pending file
|
||||
// reload records the same intent so its completion cannot undo the reset.
|
||||
if command.kind != commandEmpty {
|
||||
m.chatScrollOffset = 0
|
||||
m = m.resetChatScrollToBottom()
|
||||
}
|
||||
|
||||
return m.dispatchCommand(command)
|
||||
@@ -4791,7 +4969,9 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
if m.loopActive() {
|
||||
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /loop stop all to end them.")
|
||||
}
|
||||
return m, nil
|
||||
var clearCmd tea.Cmd
|
||||
m, clearCmd = m.refreshFileViewMarkers()
|
||||
return m, clearCmd
|
||||
case commandNew:
|
||||
// A fresh session mid-run would strand the in-flight turn's events; make the
|
||||
// user cancel first. Idle, /new saves the current session (already on disk)
|
||||
@@ -4800,7 +4980,10 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "A run is in progress. Press Esc to cancel it first, then /new."})
|
||||
return m, nil
|
||||
}
|
||||
return m.startNewSession(), nil
|
||||
next := m.startNewSession()
|
||||
var newCmd tea.Cmd
|
||||
next, newCmd = next.refreshFileViewMarkers()
|
||||
return next, newCmd
|
||||
case commandBTW:
|
||||
return m.handleBTWCommand(command.text)
|
||||
case commandLoop:
|
||||
@@ -4962,7 +5145,11 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
} else if text != "" {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
}
|
||||
return m, nil
|
||||
var resumeCmd tea.Cmd
|
||||
if text == "" {
|
||||
m, resumeCmd = m.refreshFileViewMarkers()
|
||||
}
|
||||
return m, resumeCmd
|
||||
case commandRename:
|
||||
if title := strings.TrimSpace(command.text); title != "" {
|
||||
return m.renameActiveSession(title), nil
|
||||
@@ -4982,9 +5169,10 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
return m.toggleDetailedTranscript(), nil
|
||||
case commandRewind:
|
||||
text := ""
|
||||
m, text = m.handleRewindCommand(command.text)
|
||||
var rewindCmd tea.Cmd
|
||||
m, text, rewindCmd = m.handleRewindCommand(command.text)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m, nil
|
||||
return m, rewindCmd
|
||||
case commandEffort:
|
||||
if strings.TrimSpace(command.text) == "" {
|
||||
if m.pending {
|
||||
@@ -5072,10 +5260,21 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
text := ""
|
||||
m, text = m.handleThemeCommand(command.text)
|
||||
var loadCmd tea.Cmd
|
||||
if m.fileView.active && m.fileView.mode == fileViewFull {
|
||||
m, loadCmd = m.startFileViewLoadCmd(m.chatColumnWidth())
|
||||
}
|
||||
if validThemeMode(command.text) && !strings.Contains(text, "could not save theme preference") {
|
||||
return m.showTransientNotice(m.themeAppliedNotice(), transientNoticeSuccess)
|
||||
next, noticeCmd := m.showTransientNotice(m.themeAppliedNotice(), transientNoticeSuccess)
|
||||
if loadCmd != nil {
|
||||
return next, tea.Batch(noticeCmd, loadCmd)
|
||||
}
|
||||
return next, noticeCmd
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
if loadCmd != nil {
|
||||
return m, loadCmd
|
||||
}
|
||||
return m, nil
|
||||
case commandNotify:
|
||||
// Bare `/notify` opens the popup picker so the user can pick mode + focus
|
||||
@@ -5212,7 +5411,7 @@ func (m model) executeSlash(input string) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.rememberInput(input)
|
||||
m.clearSuggestions()
|
||||
m.chatScrollOffset = 0
|
||||
m = m.resetChatScrollToBottom()
|
||||
return m.dispatchCommand(command)
|
||||
}
|
||||
|
||||
@@ -6014,6 +6213,11 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
if !toolResultCardSuppressedInTranscript(result.Name, result.Status) {
|
||||
rows = append(rows, row)
|
||||
m.sendAgentRow(runID, row)
|
||||
} else if result.Name != "Task" && toolResultMayMutateUnknownScope(result.Name) && m.runtimeMessageSink != nil {
|
||||
// A hidden successful mutating result (TaskOutput) still needs the
|
||||
// conservative refresh; Task completion carries it via
|
||||
// specialistCompleteMsg.
|
||||
m.runtimeMessageSink(unknownScopeMutationMsg{runID: runID})
|
||||
}
|
||||
// Keep the latest plan state in sync for run details and step drill-in.
|
||||
if result.Name == "update_plan" && m.registry != nil {
|
||||
|
||||
@@ -154,6 +154,11 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if next, cmd, handled := m.handlePetMouse(msg); handled {
|
||||
return next, cmd
|
||||
}
|
||||
if mouseLeftPress(msg) && m.runDetailsVisible() {
|
||||
if path, ok := m.runDetailsFileAtMouse(msg); ok {
|
||||
return m.selectFile(path)
|
||||
}
|
||||
}
|
||||
if mouseLeftPress(msg) {
|
||||
switch {
|
||||
case m.providerWizard != nil:
|
||||
|
||||
@@ -19,6 +19,22 @@ func (m model) runDetailsAllowed() bool {
|
||||
m.mcpManager == nil && m.picker == nil && m.renamePrompt == nil && !m.suggestionsActive()
|
||||
}
|
||||
|
||||
// runDetailsVisible reports whether Run details is the overlay currently
|
||||
// displayed in the viewport. Higher-priority overlays (terminal attach, speech
|
||||
// API-key prompt, keybinding help, leader help) and the detailed transcript
|
||||
// view take precedence over Run details in View/transcriptView.
|
||||
func (m model) runDetailsVisible() bool {
|
||||
if !m.runDetailsOpen || !m.runDetailsAllowed() || m.transcriptDetailed ||
|
||||
m.helpOverlay || m.leaderHelpOverlay || m.sttKeyPrompt != nil || m.terminalAttach != nil {
|
||||
return false
|
||||
}
|
||||
width := chatWidth(m.width)
|
||||
if m.terminalAttachOverlay(width) != "" || m.sttKeyPromptOverlay(width) != "" {
|
||||
return false
|
||||
}
|
||||
return m.runDetailsOverlay(width) != ""
|
||||
}
|
||||
|
||||
func (m model) runDetailsOverlay(width int) string {
|
||||
if !m.runDetailsOpen || width < runDetailsMinWidth || !m.runDetailsAllowed() {
|
||||
return ""
|
||||
@@ -26,11 +42,19 @@ func (m model) runDetailsOverlay(width int) string {
|
||||
overlayWidth := minInt(72, maxInt(40, width-8))
|
||||
overlayWidth = minInt(overlayWidth, width)
|
||||
inner := maxInt(12, overlayWidth-4)
|
||||
lines := m.runDetailsLines(inner)
|
||||
lines := m.runDetailsLayout(inner).lines
|
||||
return centerRenderedBlock(styledBlockFillTitle(overlayWidth, "Run details", lines, zeroTheme.lineStrong, lipgloss.NewStyle()), width)
|
||||
}
|
||||
|
||||
func (m model) runDetailsLines(width int) []string {
|
||||
type runDetailsLayout struct {
|
||||
lines []string
|
||||
fileHits []fileHit
|
||||
fileStart int
|
||||
}
|
||||
|
||||
func (m model) runDetailsLayout(width int) runDetailsLayout {
|
||||
var out runDetailsLayout
|
||||
out.fileStart = -1
|
||||
lines := make([]string, 0, 16)
|
||||
appendSection := func(header string, rows []string) {
|
||||
if len(rows) == 0 {
|
||||
@@ -48,8 +72,27 @@ func (m model) runDetailsLines(width int) []string {
|
||||
|
||||
appendSection(m.sidebarAgentHeader(width), m.sidebarAgentLines(width))
|
||||
appendSection(m.sidebarPlanHeader(width), m.sidebarPlanLines(width))
|
||||
fileLines, _ := m.sidebarFileLines(width)
|
||||
appendSection(m.sidebarFilesHeader(width), fileLines)
|
||||
fileLines, hits := m.sidebarFileLines(width)
|
||||
if len(fileLines) > 0 {
|
||||
if len(lines) > 0 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
lines = append(lines, m.sidebarFilesHeader(width))
|
||||
out.fileStart = len(lines)
|
||||
kept := fileLines
|
||||
if len(kept) > runDetailsMaxItems {
|
||||
kept = append(append([]string(nil), fileLines[:runDetailsMaxItems-1]...), " "+zeroTheme.faint.Render("… more in transcript"))
|
||||
var filtered []fileHit
|
||||
for _, h := range hits {
|
||||
if h.lineOffset < runDetailsMaxItems-1 {
|
||||
filtered = append(filtered, h)
|
||||
}
|
||||
}
|
||||
hits = filtered
|
||||
}
|
||||
lines = append(lines, kept...)
|
||||
out.fileHits = hits
|
||||
}
|
||||
appendSection(sidebarHeader("ACTIVITY", width), m.sidebarActivityLines(width, runDetailsMaxItems))
|
||||
if tokens := m.sidebarTokenText(); tokens != "" {
|
||||
if len(lines) > 0 {
|
||||
@@ -58,8 +101,10 @@ func (m model) runDetailsLines(width int) []string {
|
||||
lines = append(lines, zeroTheme.faint.Render(tokens))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return []string{zeroTheme.faint.Render("No active run details yet.")}
|
||||
out.lines = []string{zeroTheme.faint.Render("No active run details yet.")}
|
||||
return out
|
||||
}
|
||||
lines = append(lines, "", zeroTheme.faint.Render("Esc or Ctrl+B closes"))
|
||||
return lines
|
||||
out.lines = lines
|
||||
return out
|
||||
}
|
||||
@@ -64,3 +64,31 @@ func TestRunDetailsOverlayHidesBehindPicker(t *testing.T) {
|
||||
t.Fatalf("run details must yield to a picker that owns the keyboard, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDetailsHitsAlignWithSidebar(t *testing.T) {
|
||||
m := filesPanelTestModel()
|
||||
inner := 40
|
||||
fileLines, hits := m.sidebarFileLines(inner)
|
||||
if len(hits) == 0 || len(fileLines) == 0 {
|
||||
t.Fatal("sidebar hits")
|
||||
}
|
||||
layout := m.runDetailsLayout(inner)
|
||||
if layout.fileStart < 0 {
|
||||
t.Fatal("run details dropped FILES block identity")
|
||||
}
|
||||
if len(layout.fileHits) != len(hits) && len(fileLines) <= runDetailsMaxItems {
|
||||
t.Fatalf("hit count %d vs sidebar %d", len(layout.fileHits), len(hits))
|
||||
}
|
||||
for _, h := range layout.fileHits {
|
||||
idx := layout.fileStart + h.lineOffset
|
||||
if idx < 0 || idx >= len(layout.lines) {
|
||||
t.Fatalf("hit %q offset %d outside details", h.path, h.lineOffset)
|
||||
}
|
||||
if h.lineOffset < 0 || h.lineOffset >= len(fileLines) {
|
||||
t.Fatalf("hit %q offset %d outside sidebar lines", h.path, h.lineOffset)
|
||||
}
|
||||
if layout.lines[idx] != fileLines[h.lineOffset] {
|
||||
t.Fatalf("hit map != rendered row for %q", h.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -740,27 +740,39 @@ func (m model) runCompact() tea.Cmd {
|
||||
// handleRewindCommand restores workspace files to a checkpoint and truncates the
|
||||
// session log. "/rewind" or "/rewind latest" undoes the most recent checkpoint;
|
||||
// "/rewind <n>" rewinds to a specific event sequence.
|
||||
func (m model) handleRewindCommand(args string) (model, string) {
|
||||
func (m model) handleRewindCommand(args string) (model, string, tea.Cmd) {
|
||||
if m.sessionStore == nil || m.activeSession.SessionID == "" {
|
||||
return m, "Rewind\nno active session to rewind."
|
||||
return m, "Rewind\nno active session to rewind.", nil
|
||||
}
|
||||
if m.pending {
|
||||
return m, "Rewind\ncannot rewind while a run is in progress."
|
||||
return m, "Rewind\ncannot rewind while a run is in progress.", nil
|
||||
}
|
||||
// A cancelled run's late flush hasn't appended its checkpoint events yet:
|
||||
// ApplyRewind would prune those checkpoint blobs as unreferenced, then the
|
||||
// flush would re-append pre-rewind events after the rewind marker.
|
||||
if len(m.flushRunIDs) > 0 {
|
||||
return m, "Rewind\ncannot rewind while a cancelled run is still flushing — retry in a moment."
|
||||
return m, "Rewind\ncannot rewind while a cancelled run is still flushing — retry in a moment.", nil
|
||||
}
|
||||
arg := strings.TrimSpace(strings.ToLower(args))
|
||||
target, err := m.resolveRewindTarget(arg)
|
||||
if err != nil {
|
||||
return m, "Rewind\n" + err.Error()
|
||||
return m, "Rewind\n" + err.Error(), nil
|
||||
}
|
||||
|
||||
// Workspace files may be touched or restored starting with ApplyRewind:
|
||||
// Invalidate file view cache immediately so any partial or full disk mutation
|
||||
// cannot serve stale pre-rewind snapshots regardless of subsequent step failures.
|
||||
// The purge advances the cache generation, which retires the open view's
|
||||
// snapshot and any in-flight load without a view-local revision bump.
|
||||
defaultFileViewCache.invalidateUnknownScope()
|
||||
|
||||
report, err := m.sessionStore.ApplyRewind(m.activeSession.SessionID, m.cwd, target)
|
||||
if err != nil {
|
||||
return m, "Rewind\n" + err.Error()
|
||||
var refreshCmd tea.Cmd
|
||||
if m.fileView.active && m.fileView.mode == fileViewFull {
|
||||
m, refreshCmd = m.startFileViewRefreshCmd(m.chatColumnWidth())
|
||||
}
|
||||
return m, "Rewind\n" + err.Error(), refreshCmd
|
||||
}
|
||||
|
||||
// ApplyRewind truncated the persisted event log, restored files, and appended a
|
||||
@@ -776,7 +788,11 @@ func (m model) handleRewindCommand(args string) (model, string) {
|
||||
events, readErr := m.sessionStore.ReadEvents(m.activeSession.SessionID)
|
||||
if readErr != nil {
|
||||
m.sessionEvents = nil // drop stale context so it can't reach the next prompt
|
||||
return m, fmt.Sprintf("Rewind\nrewound to sequence %d, but reloading the session failed (in-memory context cleared): %s", target, readErr.Error())
|
||||
var refreshCmd tea.Cmd
|
||||
if m.fileView.active && m.fileView.mode == fileViewFull {
|
||||
m, refreshCmd = m.startFileViewRefreshCmd(m.chatColumnWidth())
|
||||
}
|
||||
return m, fmt.Sprintf("Rewind\nrewound to sequence %d, but reloading the session failed (in-memory context cleared): %s", target, readErr.Error()), refreshCmd
|
||||
}
|
||||
m.sessionEvents = append([]sessions.Event{}, events...)
|
||||
rows := initialTranscript()
|
||||
@@ -786,12 +802,17 @@ func (m model) handleRewindCommand(args string) (model, string) {
|
||||
// pre-rewind scrollback above it stays, as scrollback cannot be un-printed.
|
||||
m.resetFlushFrontier("· rewound ·")
|
||||
|
||||
var refreshCmd tea.Cmd
|
||||
if m.fileView.active && m.fileView.mode == fileViewFull {
|
||||
m, refreshCmd = m.startFileViewRefreshCmd(m.chatColumnWidth())
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("Rewound to sequence %d\n%d file(s) restored, %d deleted, %d skipped.",
|
||||
target, report.FilesRestored, report.FilesDeleted, len(report.Skipped))
|
||||
if len(report.Skipped) > 0 {
|
||||
summary += "\nskipped (not recoverable): " + strings.Join(report.Skipped, ", ")
|
||||
}
|
||||
return m, summary
|
||||
return m, summary, refreshCmd
|
||||
}
|
||||
|
||||
// resolveRewindTarget maps a /rewind argument to a keep-through event sequence.
|
||||
|
||||
@@ -845,7 +845,7 @@ func TestRewindRefreshesInMemorySessionState(t *testing.T) {
|
||||
}
|
||||
eventsBefore := len(m.sessionEvents)
|
||||
|
||||
m, out := m.handleRewindCommand("latest")
|
||||
m, out, _ := m.handleRewindCommand("latest")
|
||||
if !strings.Contains(out, "Rewound") {
|
||||
t.Fatalf("expected a rewind summary, got %q", out)
|
||||
}
|
||||
|
||||
@@ -203,7 +203,15 @@ func highlightCodeAuto(code []string, lang string, measure int) ([]string, bool)
|
||||
}
|
||||
|
||||
func highlightCodeForPath(code []string, path string, measure int, bg color.Color) ([]string, bool) {
|
||||
return highlightCodeWithLexer(cachedLexerForPath(path), code, measure, bg)
|
||||
return highlightCodeForPathWithTheme(code, path, measure, bg, zeroTheme)
|
||||
}
|
||||
|
||||
func highlightCodeForPathWithTheme(code []string, path string, measure int, bg color.Color, theme tuiTheme) ([]string, bool) {
|
||||
backgrounds := make([]color.Color, len(code))
|
||||
for index := range backgrounds {
|
||||
backgrounds[index] = bg
|
||||
}
|
||||
return highlightCodeWithLexerThemeAndLineBackgrounds(cachedLexerForPath(path), code, measure, theme, backgrounds, nil)
|
||||
}
|
||||
|
||||
// highlightSpan overlays a background on a rune range in one source line while
|
||||
@@ -285,6 +293,10 @@ func highlightCodeWithLexerAndSpans(lexer chroma.Lexer, code []string, measure i
|
||||
}
|
||||
|
||||
func highlightCodeWithLexerAndLineBackgrounds(lexer chroma.Lexer, code []string, measure int, backgrounds []color.Color, spans []highlightSpan) ([]string, bool) {
|
||||
return highlightCodeWithLexerThemeAndLineBackgrounds(lexer, code, measure, zeroTheme, backgrounds, spans)
|
||||
}
|
||||
|
||||
func highlightCodeWithLexerThemeAndLineBackgrounds(lexer chroma.Lexer, code []string, measure int, theme tuiTheme, backgrounds []color.Color, spans []highlightSpan) ([]string, bool) {
|
||||
if measure < 4 {
|
||||
return nil, false
|
||||
}
|
||||
@@ -367,7 +379,7 @@ func highlightCodeWithLexerAndLineBackgrounds(lexer chroma.Lexer, code []string,
|
||||
|
||||
line, column := 0, 0
|
||||
for _, token := range iterator.Tokens() {
|
||||
style := tokenStyle(token.Type)
|
||||
style := tokenStyleForTheme(theme, token.Type)
|
||||
for index, part := range strings.Split(token.Value, "\n") {
|
||||
if index > 0 {
|
||||
flushLine()
|
||||
|
||||
@@ -92,6 +92,9 @@ func applyTheme(mode themeMode, terminalDark bool) themeMode {
|
||||
if defaultRenderCache != nil {
|
||||
defaultRenderCache.clear() // old-palette entries must not be reused
|
||||
}
|
||||
if defaultFileViewCache != nil {
|
||||
defaultFileViewCache.clear()
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user