Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b46855a
perf(tui): bounded file read, LRU rendering cache, and size caps for …
hazyhaar Aug 24, 2026
b2ae03f
fix(tui): move cache-miss loading off the View path to unblock render…
hazyhaar Aug 27, 2026
f82c386
fix(tui): thread-safe palette capture and generation invalidation ret…
hazyhaar Aug 27, 2026
a2b8bc7
fix(tui): restore diff syntax background slice and reload view on git…
hazyhaar Aug 27, 2026
5a7722b
docs(agents): record resilient test lifecycle, unfiltered -race verif…
hazyhaar Aug 27, 2026
85c835f
fix(tui): unified snapshot lifecycle, zero-alloc UUIDv7 token, and no…
hazyhaar Aug 27, 2026
6165b21
fix(tui): enforce desiredSeq snapshot authority and reverse-order com…
hazyhaar Aug 27, 2026
e550c00
style(tui): apply canonical gofmt alignment
hazyhaar Aug 27, 2026
0c27632
security(tui): sanitize raw ANSI escapes and track LRU eviction stati…
hazyhaar Aug 27, 2026
669354a
fix(tui): charge line terminators against the file-view byte budget
hazyhaar Aug 30, 2026
9c9233f
fix(tui): refuse file-view cache peek across desiredSeq
hazyhaar Aug 30, 2026
b552430
fix(tui): reload file view after shell escape and skip superseded loads
hazyhaar Aug 30, 2026
74fe56d
fix(tui): open file view from a second FILES activation
hazyhaar Aug 30, 2026
ea678e6
fix(tui): refresh mutated files, stop in-flight loads, hit FILES by row
hazyhaar Aug 30, 2026
c91a074
fix(tui): do not commit superseded highlights into the file-view cache
hazyhaar Aug 30, 2026
17fa979
fix(tui): one file-view request owns abandon, cache-hit, and refresh
hazyhaar Aug 31, 2026
57411cd
fix(tui): sanitize source before highlight, hit FILES by geometry, fi…
hazyhaar Sep 1, 2026
ccf5bb2
fix(tui): align file view lifecycle, source freshness authority, and …
hazyhaar Sep 2, 2026
56b9e95
fix(tui): clarify forceReload and refreshSource in loadAndRender
hazyhaar Sep 2, 2026
180effa
fix(tui): async rewind command dispatch, FIFO non-blocking open, and …
hazyhaar Sep 5, 2026
559ae33
fix(tui): bound file-view cache by retained bytes and source hash
hazyhaar Sep 12, 2026
654e51b
fix(tui): resolve review findings on async file view lifecycle, cache…
hazyhaar Sep 12, 2026
84bf36c
fix(tui): resolve review findings on BTW recovery, scroll geometry, a…
hazyhaar Sep 15, 2026
90f7628
fix(tui): synchronize file view across surfaces on tool mutations out…
Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 68 additions & 7 deletions internal/tui/btw.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -156,10 +170,16 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
sideRunIDBase: side.runID,
}

var btwFileViewCmd tea.Cmd
if side.fileView.active && side.fileView.mode == fileViewFull {
side, btwFileViewCmd = side.startFileViewLoadCmd(side.chatColumnWidth())
}

if question == "" {
return side, nil
return side, btwFileViewCmd
}
return side.launchPrompt(question)
next, launchCmd := side.launchPrompt(question)
return next, batchCommands(btwFileViewCmd, launchCmd)
}

func (m model) leaveBTW() (model, tea.Cmd) {
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -229,9 +255,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
Expand All @@ -240,8 +266,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 {
Expand All @@ -265,13 +298,37 @@ 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
}
runID, ok := btwMessageRunID(msg)
if !ok || runID <= 0 || runID >= m.btw.sideRunIDBase {
return m, nil, false
}
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)
Expand All @@ -289,7 +346,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) {
Expand Down Expand Up @@ -320,6 +379,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:
Expand Down
67 changes: 67 additions & 0 deletions internal/tui/btw_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tui

import (
"context"
"errors"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -473,3 +474,69 @@ 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")
}
}
9 changes: 9 additions & 0 deletions internal/tui/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
Loading
Loading