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 commit 100d847aaf)

* 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 commit a1887f4b03)

* 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 on 311290c0 and 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 directly

1ce4f96e added 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 on ca1cdaf3 and 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:
authored and GitHub committed 2026-11-07 10:28:29 -08:00
1 parent 2ca84cc127
commit e034a93815
18 files changed
+6505 -142

No files matched your search

+76 -8
View File
@@ -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:
+110
View File
@@ -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)
}
}
+9
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+55
View File
@@ -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)")
}
}
+2 -2
View File
@@ -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")
}
+49 -3
View File
@@ -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
+252
View File
@@ -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)
})
}
+1 -1
View File
@@ -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
View File
@@ -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 {
+5
View File
@@ -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:
+51 -6
View File
@@ -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
}
+28
View File
@@ -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)
}
}
}
+29 -8
View File
@@ -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.
+1 -1
View File
@@ -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)
}
+14 -2
View File
@@ -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()
+3
View File
@@ -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
}