detached ecd28d4

merge(next): integrate rendering transition fixes

# Conflicts:
#	internal/usecase/daemon/transactional_resize.go
commit
ecd28d4b35a2a4c6322fa8ed4dd1260e4ba8da40
author
brice · · 2026-07-13 09:10 UTC
merge of
a2009ca + 88f2a08 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +568 -70
M internal/usecase/daemon/client.go +53 -20
@@ -14,7 +14,6 @@ import (
1414 "github.com/bnema/vev/internal/domain"
1515 "github.com/bnema/vev/internal/ports"
1616 "github.com/bnema/vev/internal/usecase/keys"
17- "github.com/bnema/vev/internal/usecase/layout"
1817 "github.com/bnema/vev/internal/usecase/mouse"
1918 themeui "github.com/bnema/vev/internal/usecase/theme"
2019 )
@@ -41,17 +40,19 @@ type attachedClient struct {
4140 // sendMu and must never share mutable backing storage.
4241 pipelineCache composeCacheInput
4342 pipelineScratch composeCacheInput
44- renderScratch renderCaptureScratch // only touched while sendMu is held
45- captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held
46- size domain.Size
47- keys *keys.Router
48- sess Guarded[*session]
49- mouseScan mouse.Scanner
50- themeMu sync.Mutex
51- theme themeui.Theme
52- clientTheme themeui.Theme
53- lastCursor cursorOut
54- renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
43+ renderScratch renderCaptureScratch // only touched while sendMu is held
44+ // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
45+ // snapshots cannot leak when an attachment switches tabs or sessions.
46+ captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
47+ size domain.Size
48+ keys *keys.Router
49+ sess Guarded[*session]
50+ mouseScan mouse.Scanner
51+ themeMu sync.Mutex
52+ theme themeui.Theme
53+ clientTheme themeui.Theme
54+ lastCursor cursorOut
55+ renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
5556 // previousSession is guarded independently. It is retained through temporary
5657 // setSession(nil) hand-offs and cleared only on terminal teardown.
5758 previousSession Guarded[*session]
@@ -113,6 +114,30 @@ func (ac *attachedClient) clearPreviousSession() {
113114 }
114115 }
115116
117+// pruneCaptureFrames releases snapshots for panes that have left their owner.
118+// Callers must not hold daemon, session, tab, or pane locks.
119+func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) {
120+ if ac == nil || len(panes) == 0 {
121+ return
122+ }
123+ ac.sendMu.Lock()
124+ for _, p := range panes {
125+ delete(ac.captureFrames, p)
126+ }
127+ ac.sendMu.Unlock()
128+}
129+
130+// clearCaptureFrames releases all pane snapshots when the attachment no longer
131+// owns a session.
132+func (ac *attachedClient) clearCaptureFrames() {
133+ if ac == nil {
134+ return
135+ }
136+ ac.sendMu.Lock()
137+ ac.captureFrames = nil
138+ ac.sendMu.Unlock()
139+}
140+
116141 func (ac *attachedClient) getTheme() themeui.Theme {
117142 ac.themeMu.Lock()
118143 defer ac.themeMu.Unlock()
@@ -383,6 +408,10 @@ func (d *Daemon) handoffCoordinator(from, target *session, old, current *attache
383408 }
384409 current.sendMu.Lock()
385410 current.output.rebase()
411+ // Capture snapshots are attachment-owned. Discard them while the attachment
412+ // is exclusively held so the transfer releases panes no longer reachable
413+ // from the target session.
414+ current.captureFrames = nil
386415 current.sendMu.Unlock()
387416 d.attachCoordinator(target, old, current, true)
388417 }
@@ -471,14 +500,18 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
471500 wsz := tb.size
472501 tb.mu.Unlock()
473502
503+ outerResizeAccepted := false
474504 if clientSize.Valid() && wsz != tabSize(clientSize) {
475- d.resizeForFirstPaint(sess, ac, clientSize)
505+ outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)
476506 }
477507 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
478- // Activation can synchronously resize a retained floating pane through the
479- // same transaction. Fold its invalidation into this mandatory Welcome frame.
480- d.activateTab(sess, tb)
481- d.invalidateRenderNow(sess, ac, true, "client.go")
508+ // Activation can synchronously resize a retained floating pane. An accepted
509+ // synchronous outer request already includes that pane and emits the reset,
510+ // but activation still performs its warmup work.
511+ activationResized := d.activateTabAfterResize(sess, tb, outerResizeAccepted)
512+ if !outerResizeAccepted && !activationResized {
513+ d.invalidateRenderNow(sess, ac, true, "client.go")
514+ }
482515 }
483516
484517 // runConnLoop is the per-connection input router: it pumps client messages
@@ -614,9 +647,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
614647 }
615648
616649 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
617-// firstPaint caller emits its full frame immediately after this commit.
618-func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) {
619- d.requestTransactionalResize(sess, ac, sz, true)
650+// returned value reports whether the synchronous request was accepted.
651+func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {
652+ return d.requestTransactionalResize(sess, ac, sz, true)
620653 }
621654
622655 // detachOnSendError drops a client whose transport failed, leaving the session
@@ -14,7 +14,6 @@ import (
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
16 "github.com/bnema/vev/internal/usecase/keys"16 "github.com/bnema/vev/internal/usecase/keys"
17- "github.com/bnema/vev/internal/usecase/layout"
18 "github.com/bnema/vev/internal/usecase/mouse"17 "github.com/bnema/vev/internal/usecase/mouse"
19 themeui "github.com/bnema/vev/internal/usecase/theme"18 themeui "github.com/bnema/vev/internal/usecase/theme"
20 )19 )
@@ -41,17 +40,19 @@ type attachedClient struct {
41 // sendMu and must never share mutable backing storage.40 // sendMu and must never share mutable backing storage.
42 pipelineCache composeCacheInput41 pipelineCache composeCacheInput
43 pipelineScratch composeCacheInput42 pipelineScratch composeCacheInput
44- renderScratch renderCaptureScratch // only touched while sendMu is held43+ renderScratch renderCaptureScratch // only touched while sendMu is held
45- captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held44+ // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
46- size domain.Size45+ // snapshots cannot leak when an attachment switches tabs or sessions.
47- keys *keys.Router46+ captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
48- sess Guarded[*session]47+ size domain.Size
49- mouseScan mouse.Scanner48+ keys *keys.Router
50- themeMu sync.Mutex49+ sess Guarded[*session]
51- theme themeui.Theme50+ mouseScan mouse.Scanner
52- clientTheme themeui.Theme51+ themeMu sync.Mutex
53- lastCursor cursorOut52+ theme themeui.Theme
54- renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held53+ clientTheme themeui.Theme
54+ lastCursor cursorOut
55+ renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
55 // previousSession is guarded independently. It is retained through temporary56 // previousSession is guarded independently. It is retained through temporary
56 // setSession(nil) hand-offs and cleared only on terminal teardown.57 // setSession(nil) hand-offs and cleared only on terminal teardown.
57 previousSession Guarded[*session]58 previousSession Guarded[*session]
@@ -113,6 +114,30 @@ func (ac *attachedClient) clearPreviousSession() {
113 }114 }
114 }115 }
115 116
117+// pruneCaptureFrames releases snapshots for panes that have left their owner.
118+// Callers must not hold daemon, session, tab, or pane locks.
119+func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) {
120+ if ac == nil || len(panes) == 0 {
121+ return
122+ }
123+ ac.sendMu.Lock()
124+ for _, p := range panes {
125+ delete(ac.captureFrames, p)
126+ }
127+ ac.sendMu.Unlock()
128+}
129+
130+// clearCaptureFrames releases all pane snapshots when the attachment no longer
131+// owns a session.
132+func (ac *attachedClient) clearCaptureFrames() {
133+ if ac == nil {
134+ return
135+ }
136+ ac.sendMu.Lock()
137+ ac.captureFrames = nil
138+ ac.sendMu.Unlock()
139+}
140+
116 func (ac *attachedClient) getTheme() themeui.Theme {141 func (ac *attachedClient) getTheme() themeui.Theme {
117 ac.themeMu.Lock()142 ac.themeMu.Lock()
118 defer ac.themeMu.Unlock()143 defer ac.themeMu.Unlock()
@@ -383,6 +408,10 @@ func (d *Daemon) handoffCoordinator(from, target *session, old, current *attache
383 }408 }
384 current.sendMu.Lock()409 current.sendMu.Lock()
385 current.output.rebase()410 current.output.rebase()
411+ // Capture snapshots are attachment-owned. Discard them while the attachment
412+ // is exclusively held so the transfer releases panes no longer reachable
413+ // from the target session.
414+ current.captureFrames = nil
386 current.sendMu.Unlock()415 current.sendMu.Unlock()
387 d.attachCoordinator(target, old, current, true)416 d.attachCoordinator(target, old, current, true)
388 }417 }
@@ -471,14 +500,18 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
471 wsz := tb.size500 wsz := tb.size
472 tb.mu.Unlock()501 tb.mu.Unlock()
473 502
503+ outerResizeAccepted := false
474 if clientSize.Valid() && wsz != tabSize(clientSize) {504 if clientSize.Valid() && wsz != tabSize(clientSize) {
475- d.resizeForFirstPaint(sess, ac, clientSize)505+ outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)
476 }506 }
477 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)507 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
478- // Activation can synchronously resize a retained floating pane through the508+ // Activation can synchronously resize a retained floating pane. An accepted
479- // same transaction. Fold its invalidation into this mandatory Welcome frame.509+ // synchronous outer request already includes that pane and emits the reset,
480- d.activateTab(sess, tb)510+ // but activation still performs its warmup work.
481- d.invalidateRenderNow(sess, ac, true, "client.go")511+ activationResized := d.activateTabAfterResize(sess, tb, outerResizeAccepted)
512+ if !outerResizeAccepted && !activationResized {
513+ d.invalidateRenderNow(sess, ac, true, "client.go")
514+ }
482 }515 }
483 516
484 // runConnLoop is the per-connection input router: it pumps client messages517 // runConnLoop is the per-connection input router: it pumps client messages
@@ -614,9 +647,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
614 }647 }
615 648
616 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The649 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
617-// firstPaint caller emits its full frame immediately after this commit.650+// returned value reports whether the synchronous request was accepted.
618-func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) {651+func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {
619- d.requestTransactionalResize(sess, ac, sz, true)652+ return d.requestTransactionalResize(sess, ac, sz, true)
620 }653 }
621 654
622 // detachOnSendError drops a client whose transport failed, leaving the session655 // detachOnSendError drops a client whose transport failed, leaving the session
M internal/usecase/daemon/floating.go +18 -4
@@ -154,9 +154,15 @@ func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
154154 // activateTab performs the work associated with making a tab the destination.
155155 // It is deliberately idempotent: every tab transition can use this single
156156 // hook, while inactive restored tabs remain cold until actually selected.
157-func (d *Daemon) activateTab(sess *session, tb *tab) {
157+func (d *Daemon) activateTab(sess *session, tb *tab) bool {
158+ return d.activateTabAfterResize(sess, tb, false)
159+}
160+
161+// activateTabAfterResize retains tab activation's warmup work while avoiding a
162+// second resize when a synchronous outer resize request was already accepted.
163+func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccepted bool) bool {
158164 if d == nil || sess == nil || tb == nil {
159- return
165+ return false
160166 }
161167 // A headless session has no actual terminal destination. Deferring warmup
162168 // until firstPaint keeps restored tabs cold and avoids launching children
@@ -166,16 +172,20 @@ func (d *Daemon) activateTab(sess *session, tb *tab) {
166172 sess.mu.Unlock()
167173 d.exitCopyMode(ac)
168174 if ac == nil {
169- return
175+ return false
170176 }
171177 d.ensureFloatingWarm(sess, tb)
178+ if outerResizeAccepted {
179+ return false
180+ }
172181 tb.mu.Lock()
173182 hasFloating := tb.floating.pane != nil
174183 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
175184 tb.mu.Unlock()
176185 if hasFloating {
177- d.requestTransactionalResize(sess, ac, size, true)
186+ return d.requestTransactionalResize(sess, ac, size, true)
178187 }
188+ return false
179189 }
180190
181191 // toggleFloating changes only this tab's slot. Opening an uninitialized slot
@@ -427,6 +437,9 @@ func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64
427437 sess.mu.Lock()
428438 ac := sess.client
429439 sess.mu.Unlock()
440+ if ac != nil {
441+ ac.pruneCaptureFrames(p)
442+ }
430443 copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
431444 if ac != nil && (visible || copyCleared) {
432445 d.invalidateRender(sess, ac, true, "floating.go")
@@ -443,6 +456,7 @@ func (d *Daemon) teardownFloating(tb *tab, ac *attachedClient) {
443456 tb.mu.Unlock()
444457 if ac != nil {
445458 ac.overlays.clearCopyModeForPane(p)
459+ ac.pruneCaptureFrames(p)
446460 }
447461 closeFloatingPane(p)
448462 }
@@ -154,9 +154,15 @@ func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
154 // activateTab performs the work associated with making a tab the destination.154 // activateTab performs the work associated with making a tab the destination.
155 // It is deliberately idempotent: every tab transition can use this single155 // It is deliberately idempotent: every tab transition can use this single
156 // hook, while inactive restored tabs remain cold until actually selected.156 // hook, while inactive restored tabs remain cold until actually selected.
157-func (d *Daemon) activateTab(sess *session, tb *tab) {157+func (d *Daemon) activateTab(sess *session, tb *tab) bool {
158+ return d.activateTabAfterResize(sess, tb, false)
159+}
160+
161+// activateTabAfterResize retains tab activation's warmup work while avoiding a
162+// second resize when a synchronous outer resize request was already accepted.
163+func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccepted bool) bool {
158 if d == nil || sess == nil || tb == nil {164 if d == nil || sess == nil || tb == nil {
159- return165+ return false
160 }166 }
161 // A headless session has no actual terminal destination. Deferring warmup167 // A headless session has no actual terminal destination. Deferring warmup
162 // until firstPaint keeps restored tabs cold and avoids launching children168 // until firstPaint keeps restored tabs cold and avoids launching children
@@ -166,16 +172,20 @@ func (d *Daemon) activateTab(sess *session, tb *tab) {
166 sess.mu.Unlock()172 sess.mu.Unlock()
167 d.exitCopyMode(ac)173 d.exitCopyMode(ac)
168 if ac == nil {174 if ac == nil {
169- return175+ return false
170 }176 }
171 d.ensureFloatingWarm(sess, tb)177 d.ensureFloatingWarm(sess, tb)
178+ if outerResizeAccepted {
179+ return false
180+ }
172 tb.mu.Lock()181 tb.mu.Lock()
173 hasFloating := tb.floating.pane != nil182 hasFloating := tb.floating.pane != nil
174 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}183 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
175 tb.mu.Unlock()184 tb.mu.Unlock()
176 if hasFloating {185 if hasFloating {
177- d.requestTransactionalResize(sess, ac, size, true)186+ return d.requestTransactionalResize(sess, ac, size, true)
178 }187 }
188+ return false
179 }189 }
180 190
181 // toggleFloating changes only this tab's slot. Opening an uninitialized slot191 // toggleFloating changes only this tab's slot. Opening an uninitialized slot
@@ -427,6 +437,9 @@ func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64
427 sess.mu.Lock()437 sess.mu.Lock()
428 ac := sess.client438 ac := sess.client
429 sess.mu.Unlock()439 sess.mu.Unlock()
440+ if ac != nil {
441+ ac.pruneCaptureFrames(p)
442+ }
430 copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)443 copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
431 if ac != nil && (visible || copyCleared) {444 if ac != nil && (visible || copyCleared) {
432 d.invalidateRender(sess, ac, true, "floating.go")445 d.invalidateRender(sess, ac, true, "floating.go")
@@ -443,6 +456,7 @@ func (d *Daemon) teardownFloating(tb *tab, ac *attachedClient) {
443 tb.mu.Unlock()456 tb.mu.Unlock()
444 if ac != nil {457 if ac != nil {
445 ac.overlays.clearCopyModeForPane(p)458 ac.overlays.clearCopyModeForPane(p)
459+ ac.pruneCaptureFrames(p)
446 }460 }
447 closeFloatingPane(p)461 closeFloatingPane(p)
448 }462 }
M internal/usecase/daemon/input_test.go +71 -0
@@ -87,6 +87,77 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
8787 d.sessWg.Wait()
8888 }
8989
90+func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
91+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil)
92+ source := sess.tabs[0].focusedPane()
93+ target := sess.tabs[1].focusedPane()
94+ require.Equal(t, source.id, target.id, "tabs deliberately reuse pane-1")
95+
96+ source.mu.Lock()
97+ source.screen.Write([]byte("source"))
98+ source.mu.Unlock()
99+ d.paint(sess, ac, true)
100+ first := <-sends
101+ firstOutput, err := ports.UnmarshalOutput(first.Payload)
102+ require.NoError(t, err)
103+ terminal := vt.NewScreen(ac.size.Cols, ac.size.Rows)
104+ terminal.Write(firstOutput.Data)
105+
106+ // A clean pane relies on the attachment capture cache for its retained
107+ // snapshot. Both tabs deliberately use the tab-local default pane ID.
108+ target.mu.Lock()
109+ target.screen.ClearDamage()
110+ target.mu.Unlock()
111+ // Use the real key action: it requests the mandatory complete repaint for
112+ // the first target-tab frame.
113+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2)
114+ require.Equal(t, 1, activeTabIndex(sess))
115+
116+ second := <-sends
117+ secondOutput, err := ports.UnmarshalOutput(second.Payload)
118+ require.NoError(t, err)
119+ require.Zero(t, secondOutput.BaseStateNum, "tab switch must emit the complete target frame first")
120+ terminal.Write(secondOutput.Data)
121+ require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells")
122+}
123+
124+func TestClosePanePrunesAttachedCaptureFrameAndKeepsSurvivor(t *testing.T) {
125+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
126+ tb := sess.tabs[0]
127+ survivor := tb.panes["pane-1"]
128+ closed := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 23})
129+ tb.mu.Lock()
130+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
131+ tb.panes[closed.id] = closed
132+ tb.mu.Unlock()
133+
134+ cachedSurvivor := capturedPaneRenderState{title: "survivor"}
135+ ac.captureFrames = map[*pane]capturedPaneRenderState{
136+ survivor: cachedSurvivor,
137+ closed: {title: "closed"},
138+ }
139+
140+ require.NoError(t, d.closePane(sess, tb, closed.id, ac, false))
141+ require.NotContains(t, ac.captureFrames, closed, "closed pane must not remain strongly retained by its attachment")
142+ require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving pane keeps its incremental capture")
143+}
144+
145+func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) {
146+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
147+ closedTab, survivorTab := sess.tabs[0], sess.tabs[1]
148+ closed := closedTab.panes["pane-1"]
149+ survivor := survivorTab.panes["pane-1"]
150+ cachedSurvivor := capturedPaneRenderState{title: "survivor"}
151+ ac.captureFrames = map[*pane]capturedPaneRenderState{
152+ closed: {title: "closed"},
153+ survivor: cachedSurvivor,
154+ }
155+
156+ d.closeTab(sess, closedTab, false)
157+ require.NotContains(t, ac.captureFrames, closed, "closed tab panes must not remain strongly retained by its attachment")
158+ require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving tab pane keeps its incremental capture")
159+}
160+
90161 func TestAltCForwardsToPTY(t *testing.T) {
91162 writes := make(chan []byte, 2)
92163 p, releasePTY := newBlockingPTYWithWrites(t, writes)
@@ -87,6 +87,77 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
87 d.sessWg.Wait()87 d.sessWg.Wait()
88 }88 }
89 89
90+func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
91+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil, nil)
92+ source := sess.tabs[0].focusedPane()
93+ target := sess.tabs[1].focusedPane()
94+ require.Equal(t, source.id, target.id, "tabs deliberately reuse pane-1")
95+
96+ source.mu.Lock()
97+ source.screen.Write([]byte("source"))
98+ source.mu.Unlock()
99+ d.paint(sess, ac, true)
100+ first := <-sends
101+ firstOutput, err := ports.UnmarshalOutput(first.Payload)
102+ require.NoError(t, err)
103+ terminal := vt.NewScreen(ac.size.Cols, ac.size.Rows)
104+ terminal.Write(firstOutput.Data)
105+
106+ // A clean pane relies on the attachment capture cache for its retained
107+ // snapshot. Both tabs deliberately use the tab-local default pane ID.
108+ target.mu.Lock()
109+ target.screen.ClearDamage()
110+ target.mu.Unlock()
111+ // Use the real key action: it requests the mandatory complete repaint for
112+ // the first target-tab frame.
113+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2)
114+ require.Equal(t, 1, activeTabIndex(sess))
115+
116+ second := <-sends
117+ secondOutput, err := ports.UnmarshalOutput(second.Payload)
118+ require.NoError(t, err)
119+ require.Zero(t, secondOutput.BaseStateNum, "tab switch must emit the complete target frame first")
120+ terminal.Write(secondOutput.Data)
121+ require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells")
122+}
123+
124+func TestClosePanePrunesAttachedCaptureFrameAndKeepsSurvivor(t *testing.T) {
125+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
126+ tb := sess.tabs[0]
127+ survivor := tb.panes["pane-1"]
128+ closed := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 23})
129+ tb.mu.Lock()
130+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
131+ tb.panes[closed.id] = closed
132+ tb.mu.Unlock()
133+
134+ cachedSurvivor := capturedPaneRenderState{title: "survivor"}
135+ ac.captureFrames = map[*pane]capturedPaneRenderState{
136+ survivor: cachedSurvivor,
137+ closed: {title: "closed"},
138+ }
139+
140+ require.NoError(t, d.closePane(sess, tb, closed.id, ac, false))
141+ require.NotContains(t, ac.captureFrames, closed, "closed pane must not remain strongly retained by its attachment")
142+ require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving pane keeps its incremental capture")
143+}
144+
145+func TestCloseTabPrunesAttachedCaptureFrames(t *testing.T) {
146+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil, nil)
147+ closedTab, survivorTab := sess.tabs[0], sess.tabs[1]
148+ closed := closedTab.panes["pane-1"]
149+ survivor := survivorTab.panes["pane-1"]
150+ cachedSurvivor := capturedPaneRenderState{title: "survivor"}
151+ ac.captureFrames = map[*pane]capturedPaneRenderState{
152+ closed: {title: "closed"},
153+ survivor: cachedSurvivor,
154+ }
155+
156+ d.closeTab(sess, closedTab, false)
157+ require.NotContains(t, ac.captureFrames, closed, "closed tab panes must not remain strongly retained by its attachment")
158+ require.Equal(t, cachedSurvivor, ac.captureFrames[survivor], "surviving tab pane keeps its incremental capture")
159+}
160+
90 func TestAltCForwardsToPTY(t *testing.T) {161 func TestAltCForwardsToPTY(t *testing.T) {
91 writes := make(chan []byte, 2)162 writes := make(chan []byte, 2)
92 p, releasePTY := newBlockingPTYWithWrites(t, writes)163 p, releasePTY := newBlockingPTYWithWrites(t, writes)
M internal/usecase/daemon/pane_actions.go +1 -0
@@ -255,6 +255,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
255255
256256 if ac != nil {
257257 ac.overlays.clearCopyModeForPane(p)
258+ ac.pruneCaptureFrames(p)
258259 }
259260
260261 if rc := sess.renderCoordinator(); rc != nil {
@@ -255,6 +255,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
255 255
256 if ac != nil {256 if ac != nil {
257 ac.overlays.clearCopyModeForPane(p)257 ac.overlays.clearCopyModeForPane(p)
258+ ac.pruneCaptureFrames(p)
258 }259 }
259 260
260 if rc := sess.renderCoordinator(); rc != nil {261 if rc := sess.renderCoordinator(); rc != nil {
M internal/usecase/daemon/picker_test.go +43 -0
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "context"
5+ "strings"
56 "testing"
67 "time"
78
@@ -14,6 +15,7 @@ import (
1415 "github.com/bnema/vev/internal/usecase/keys"
1516 "github.com/bnema/vev/internal/usecase/layout"
1617 "github.com/bnema/vev/internal/usecase/picker"
18+ "github.com/bnema/vev/pkg/vt"
1719 )
1820
1921 // --- test doubles -----------------------------------------------------------
@@ -216,6 +218,47 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
216218 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
217219 }
218220
221+func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
222+ p1, releasePTY1 := newBlockingPTY(t)
223+ p2, releasePTY2 := newBlockingPTY(t)
224+ defer releasePTY1()
225+ defer releasePTY2()
226+ d, source, ac, sends := newManualSessionWithPTYs(t, p1)
227+ source.id, source.name = "source", "source"
228+ delete(d.sessions, domain.SessionID("manual"))
229+ d.sessions[source.id] = source
230+ target := &session{
231+ id: "target", name: "target", ctx: source.ctx, cancel: func() {},
232+ tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})},
233+ }
234+ d.sessions[target.id] = target
235+
236+ sourcePane := source.tabs[0].focusedPane()
237+ targetPane := target.tabs[0].focusedPane()
238+ require.Equal(t, layout.PaneID("pane-1"), sourcePane.id, "source uses reusable pane-1")
239+ require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1")
240+ sourcePane.screen.Write([]byte("SOURCE"))
241+ client := vt.NewScreen(80, 25)
242+ d.firstPaint(source, ac, ac.size)
243+ sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
244+ ac.ackOutputState(sourceOutput.NewStateNum)
245+
246+ targetPane.screen.Write([]byte("TARGET"))
247+ targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
248+ require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")
249+ ac.previousSession.Set(target)
250+
251+ // Exercise the user-facing previous-session route, which delegates to the
252+ // real switchToTarget hand-off and immediately emits its required reset.
253+ d.backSession(source, ac)
254+ firstReset := awaitFrame(t, sends, ports.MsgOutput)
255+ out := mustApplyOutput(t, client, firstReset)
256+ require.Zero(t, out.BaseStateNum, "the first target frame must be the reset, not an eventual repair")
257+ frame := strings.Join(frameRows(client.Frame), "\n")
258+ require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture")
259+ require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")
260+}
261+
219262 func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
220263 p1, releasePTY1 := newBlockingPTY(t)
221264 p2, releasePTY2 := newBlockingPTY(t)
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "strings"
5 "testing"6 "testing"
6 "time"7 "time"
7 8
@@ -14,6 +15,7 @@ import (
14 "github.com/bnema/vev/internal/usecase/keys"15 "github.com/bnema/vev/internal/usecase/keys"
15 "github.com/bnema/vev/internal/usecase/layout"16 "github.com/bnema/vev/internal/usecase/layout"
16 "github.com/bnema/vev/internal/usecase/picker"17 "github.com/bnema/vev/internal/usecase/picker"
18+ "github.com/bnema/vev/pkg/vt"
17 )19 )
18 20
19 // --- test doubles -----------------------------------------------------------21 // --- test doubles -----------------------------------------------------------
@@ -216,6 +218,47 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
216 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)218 require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
217 }219 }
218 220
221+func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
222+ p1, releasePTY1 := newBlockingPTY(t)
223+ p2, releasePTY2 := newBlockingPTY(t)
224+ defer releasePTY1()
225+ defer releasePTY2()
226+ d, source, ac, sends := newManualSessionWithPTYs(t, p1)
227+ source.id, source.name = "source", "source"
228+ delete(d.sessions, domain.SessionID("manual"))
229+ d.sessions[source.id] = source
230+ target := &session{
231+ id: "target", name: "target", ctx: source.ctx, cancel: func() {},
232+ tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})},
233+ }
234+ d.sessions[target.id] = target
235+
236+ sourcePane := source.tabs[0].focusedPane()
237+ targetPane := target.tabs[0].focusedPane()
238+ require.Equal(t, layout.PaneID("pane-1"), sourcePane.id, "source uses reusable pane-1")
239+ require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1")
240+ sourcePane.screen.Write([]byte("SOURCE"))
241+ client := vt.NewScreen(80, 25)
242+ d.firstPaint(source, ac, ac.size)
243+ sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
244+ ac.ackOutputState(sourceOutput.NewStateNum)
245+
246+ targetPane.screen.Write([]byte("TARGET"))
247+ targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
248+ require.Empty(t, targetPane.screen.Damage(), "target deliberately has no pending VT damage")
249+ ac.previousSession.Set(target)
250+
251+ // Exercise the user-facing previous-session route, which delegates to the
252+ // real switchToTarget hand-off and immediately emits its required reset.
253+ d.backSession(source, ac)
254+ firstReset := awaitFrame(t, sends, ports.MsgOutput)
255+ out := mustApplyOutput(t, client, firstReset)
256+ require.Zero(t, out.BaseStateNum, "the first target frame must be the reset, not an eventual repair")
257+ frame := strings.Join(frameRows(client.Frame), "\n")
258+ require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture")
259+ require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")
260+}
261+
219 func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {262 func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
220 p1, releasePTY1 := newBlockingPTY(t)263 p1, releasePTY1 := newBlockingPTY(t)
221 p2, releasePTY2 := newBlockingPTY(t)264 p2, releasePTY2 := newBlockingPTY(t)
M internal/usecase/daemon/render_capture.go +29 -14
@@ -132,7 +132,8 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCapt
132132 width := min(max(visible.Width, 0), p.screen.Frame.Width)
133133 height := min(max(visible.Height, 0), p.screen.Frame.Height)
134134 needsFrame := len(damage.Damage) > 0
135- if out.frame.Width != width || out.frame.Height != height {
135+ frameChanged := out.frame.Width != width || out.frame.Height != height
136+ if frameChanged {
136137 out.frame = renderer.NewFrame(width, height)
137138 needsFrame = true
138139 }
@@ -144,7 +145,10 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCapt
144145 }
145146 }
146147
147- if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {
148+ if frameChanged || uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {
149+ // A cache entry with a new frame has no terminal-shadow equivalent.
150+ // Redraw it even when the pane has no VT damage, such as an untouched
151+ // pane first made visible after a tab or session switch.
148152 out.damage = []renderer.Damage{renderer.FullRedraw()}
149153 } else {
150154 out.damage = append(out.damage[:0], damage.Damage...)
@@ -194,8 +198,10 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
194198 bars.status.tabs = scratch.statusTabs
195199 scratch.mru = append(scratch.mru[:0], bars.mru...)
196200 bars.mru = scratch.mru
197- scratch.ranked = append(scratch.ranked[:0], bars.rankedRecent...)
198- bars.rankedRecent = scratch.ranked
201+ if bars.rankedRecent != nil {
202+ scratch.ranked = copyRankedRecentInto(scratch.ranked, bars.rankedRecent)
203+ bars.rankedRecent = scratch.ranked
204+ }
199205 if len(preview.Rows) > 0 {
200206 preview = clonePickerPreview(preview)
201207 } else {
@@ -221,6 +227,9 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
221227 }
222228 state.panes = scratch.panes[:0]
223229 state.tabGeneration = uint64(len(layoutSnap.fingerprint))
230+ if ac.captureFrames == nil {
231+ ac.captureFrames = make(map[*pane]capturedPaneRenderState)
232+ }
224233 for _, placement := range layoutSnap.placements {
225234 p := tb.panes[placement.ID]
226235 if p == nil {
@@ -231,10 +240,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
231240 visible = domain.Rect{}
232241 }
233242 p.mu.Lock()
234- if ac.captureFrames == nil {
235- ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
236- }
237- captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p.id])
243+ captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p])
238244 if mode == damageCaptureConsume {
239245 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
240246 }
@@ -248,7 +254,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
248254 translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
249255 }
250256 captured.rawDamage, captured.damage = captured.damage, translated
251- ac.captureFrames[captured.id] = captured
257+ ac.captureFrames[p] = captured
252258 state.panes = append(state.panes, captured)
253259 }
254260 scratch.panes = state.panes
@@ -256,10 +262,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
256262 p := tb.floating.pane
257263 p.mu.Lock()
258264 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
259- if ac.captureFrames == nil {
260- ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
261- }
262- captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p.id])
265+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p])
263266 if mode == damageCaptureConsume {
264267 seen := false
265268 for _, receipt := range state.receipts {
@@ -272,7 +275,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
272275 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
273276 }
274277 }
275- ac.captureFrames[captured.id] = captured
278+ ac.captureFrames[p] = captured
276279 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
277280 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
278281 p.mu.Unlock()
@@ -281,6 +284,18 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
281284 return state, true
282285 }
283286
287+// copyRankedRecentInto preserves the non-nil empty slice that selects
288+// contextual recent-session mode while retaining scratch capacity for reuse.
289+func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
290+ if len(src) == 0 {
291+ if dst != nil {
292+ return dst[:0]
293+ }
294+ return []rankedRecent{}
295+ }
296+ return append(dst[:0], src...)
297+}
298+
284299 func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) *layout.Node {
285300 if src == nil {
286301 return nil
@@ -132,7 +132,8 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCapt
132 width := min(max(visible.Width, 0), p.screen.Frame.Width)132 width := min(max(visible.Width, 0), p.screen.Frame.Width)
133 height := min(max(visible.Height, 0), p.screen.Frame.Height)133 height := min(max(visible.Height, 0), p.screen.Frame.Height)
134 needsFrame := len(damage.Damage) > 0134 needsFrame := len(damage.Damage) > 0
135- if out.frame.Width != width || out.frame.Height != height {135+ frameChanged := out.frame.Width != width || out.frame.Height != height
136+ if frameChanged {
136 out.frame = renderer.NewFrame(width, height)137 out.frame = renderer.NewFrame(width, height)
137 needsFrame = true138 needsFrame = true
138 }139 }
@@ -144,7 +145,10 @@ func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCapt
144 }145 }
145 }146 }
146 147
147- if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {148+ if frameChanged || uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {
149+ // A cache entry with a new frame has no terminal-shadow equivalent.
150+ // Redraw it even when the pane has no VT damage, such as an untouched
151+ // pane first made visible after a tab or session switch.
148 out.damage = []renderer.Damage{renderer.FullRedraw()}152 out.damage = []renderer.Damage{renderer.FullRedraw()}
149 } else {153 } else {
150 out.damage = append(out.damage[:0], damage.Damage...)154 out.damage = append(out.damage[:0], damage.Damage...)
@@ -194,8 +198,10 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
194 bars.status.tabs = scratch.statusTabs198 bars.status.tabs = scratch.statusTabs
195 scratch.mru = append(scratch.mru[:0], bars.mru...)199 scratch.mru = append(scratch.mru[:0], bars.mru...)
196 bars.mru = scratch.mru200 bars.mru = scratch.mru
197- scratch.ranked = append(scratch.ranked[:0], bars.rankedRecent...)201+ if bars.rankedRecent != nil {
198- bars.rankedRecent = scratch.ranked202+ scratch.ranked = copyRankedRecentInto(scratch.ranked, bars.rankedRecent)
203+ bars.rankedRecent = scratch.ranked
204+ }
199 if len(preview.Rows) > 0 {205 if len(preview.Rows) > 0 {
200 preview = clonePickerPreview(preview)206 preview = clonePickerPreview(preview)
201 } else {207 } else {
@@ -221,6 +227,9 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
221 }227 }
222 state.panes = scratch.panes[:0]228 state.panes = scratch.panes[:0]
223 state.tabGeneration = uint64(len(layoutSnap.fingerprint))229 state.tabGeneration = uint64(len(layoutSnap.fingerprint))
230+ if ac.captureFrames == nil {
231+ ac.captureFrames = make(map[*pane]capturedPaneRenderState)
232+ }
224 for _, placement := range layoutSnap.placements {233 for _, placement := range layoutSnap.placements {
225 p := tb.panes[placement.ID]234 p := tb.panes[placement.ID]
226 if p == nil {235 if p == nil {
@@ -231,10 +240,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
231 visible = domain.Rect{}240 visible = domain.Rect{}
232 }241 }
233 p.mu.Lock()242 p.mu.Lock()
234- if ac.captureFrames == nil {243+ captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p])
235- ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
236- }
237- captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p.id])
238 if mode == damageCaptureConsume {244 if mode == damageCaptureConsume {
239 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})245 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
240 }246 }
@@ -248,7 +254,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
248 translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)254 translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
249 }255 }
250 captured.rawDamage, captured.damage = captured.damage, translated256 captured.rawDamage, captured.damage = captured.damage, translated
251- ac.captureFrames[captured.id] = captured257+ ac.captureFrames[p] = captured
252 state.panes = append(state.panes, captured)258 state.panes = append(state.panes, captured)
253 }259 }
254 scratch.panes = state.panes260 scratch.panes = state.panes
@@ -256,10 +262,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
256 p := tb.floating.pane262 p := tb.floating.pane
257 p.mu.Lock()263 p.mu.Lock()
258 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))264 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
259- if ac.captureFrames == nil {265+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p])
260- ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
261- }
262- captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p.id])
263 if mode == damageCaptureConsume {266 if mode == damageCaptureConsume {
264 seen := false267 seen := false
265 for _, receipt := range state.receipts {268 for _, receipt := range state.receipts {
@@ -272,7 +275,7 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
272 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})275 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
273 }276 }
274 }277 }
275- ac.captureFrames[captured.id] = captured278+ ac.captureFrames[p] = captured
276 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}279 state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
277 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)280 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
278 p.mu.Unlock()281 p.mu.Unlock()
@@ -281,6 +284,18 @@ func captureRenderState(sess *session, ac *attachedClient, bars barState, overla
281 return state, true284 return state, true
282 }285 }
283 286
287+// copyRankedRecentInto preserves the non-nil empty slice that selects
288+// contextual recent-session mode while retaining scratch capacity for reuse.
289+func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
290+ if len(src) == 0 {
291+ if dst != nil {
292+ return dst[:0]
293+ }
294+ return []rankedRecent{}
295+ }
296+ return append(dst[:0], src...)
297+}
298+
284 func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) *layout.Node {299 func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) *layout.Node {
285 if src == nil {300 if src == nil {
286 return nil301 return nil
M internal/usecase/daemon/render_coordinator.go +15 -6
@@ -274,16 +274,24 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
274274 // attachment. This check shares c.mu with replacement, making a stale
275275 // attachment's timer callback unable to enqueue work for its replacement.
276276 // A nil source represents an internal pane/session invalidation.
277-func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) {
277+func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) bool {
278+ return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv)
279+}
280+
281+// invalidateForAttachmentAtResizeEpoch additionally requires epoch to remain
282+// current when nonzero, so a completed resize cannot publish after a newer
283+// request supersedes it.
284+func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attachedClient, epoch uint64, inv renderInvalidation) bool {
278285 c.mu.Lock()
279286 // An unbound coordinator is the manual/headless harness state; there is no
280287 // published production attachment to protect there. A parked target may
281288 // still publish its own PTY/session mutations to picker observers, but no
282289 // attachment-owned callback may revive that target's render path.
283290 detachedPreviewOnly := c.detached && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
284- if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) {
291+ if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) ||
292+ (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source)) {
285293 c.mu.Unlock()
286- return
294+ return false
287295 }
288296 onInvalidate := c.opts.onInvalidate
289297 c.metrics.invalidations.Add(1)
@@ -331,7 +339,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
331339 if onInvalidate != nil {
332340 onInvalidate(inv)
333341 }
334- return
342+ return true
335343 }
336344
337345 // NewTimer and C may re-enter the coordinator. Publish only if this
@@ -351,7 +359,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
351359 if onInvalidate != nil {
352360 onInvalidate(inv)
353361 }
354- return
362+ return true
355363 }
356364 if timerC == nil {
357365 stopTimer(timer)
@@ -359,7 +367,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
359367 if onInvalidate != nil {
360368 onInvalidate(inv)
361369 }
362- return
370+ return true
363371 }
364372 go func() {
365373 defer close(done)
@@ -377,6 +385,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
377385 onInvalidate(inv)
378386 }
379387 runtime.Gosched()
388+ return true
380389 }
381390
382391 // notifyAck reports that the client acknowledged an output state, releasing
@@ -274,16 +274,24 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) {
274 // attachment. This check shares c.mu with replacement, making a stale274 // attachment. This check shares c.mu with replacement, making a stale
275 // attachment's timer callback unable to enqueue work for its replacement.275 // attachment's timer callback unable to enqueue work for its replacement.
276 // A nil source represents an internal pane/session invalidation.276 // A nil source represents an internal pane/session invalidation.
277-func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) {277+func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) bool {
278+ return c.invalidateForAttachmentAtResizeEpoch(source, 0, inv)
279+}
280+
281+// invalidateForAttachmentAtResizeEpoch additionally requires epoch to remain
282+// current when nonzero, so a completed resize cannot publish after a newer
283+// request supersedes it.
284+func (c *renderCoordinator) invalidateForAttachmentAtResizeEpoch(source *attachedClient, epoch uint64, inv renderInvalidation) bool {
278 c.mu.Lock()285 c.mu.Lock()
279 // An unbound coordinator is the manual/headless harness state; there is no286 // An unbound coordinator is the manual/headless harness state; there is no
280 // published production attachment to protect there. A parked target may287 // published production attachment to protect there. A parked target may
281 // still publish its own PTY/session mutations to picker observers, but no288 // still publish its own PTY/session mutations to picker observers, but no
282 // attachment-owned callback may revive that target's render path.289 // attachment-owned callback may revive that target's render path.
283 detachedPreviewOnly := c.detached && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)290 detachedPreviewOnly := c.detached && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
284- if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) {291+ if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) ||
292+ (epoch != 0 && (c.resize.epoch != epoch || c.resize.source != source)) {
285 c.mu.Unlock()293 c.mu.Unlock()
286- return294+ return false
287 }295 }
288 onInvalidate := c.opts.onInvalidate296 onInvalidate := c.opts.onInvalidate
289 c.metrics.invalidations.Add(1)297 c.metrics.invalidations.Add(1)
@@ -331,7 +339,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
331 if onInvalidate != nil {339 if onInvalidate != nil {
332 onInvalidate(inv)340 onInvalidate(inv)
333 }341 }
334- return342+ return true
335 }343 }
336 344
337 // NewTimer and C may re-enter the coordinator. Publish only if this345 // NewTimer and C may re-enter the coordinator. Publish only if this
@@ -351,7 +359,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
351 if onInvalidate != nil {359 if onInvalidate != nil {
352 onInvalidate(inv)360 onInvalidate(inv)
353 }361 }
354- return362+ return true
355 }363 }
356 if timerC == nil {364 if timerC == nil {
357 stopTimer(timer)365 stopTimer(timer)
@@ -359,7 +367,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
359 if onInvalidate != nil {367 if onInvalidate != nil {
360 onInvalidate(inv)368 onInvalidate(inv)
361 }369 }
362- return370+ return true
363 }371 }
364 go func() {372 go func() {
365 defer close(done)373 defer close(done)
@@ -377,6 +385,7 @@ func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv
377 onInvalidate(inv)385 onInvalidate(inv)
378 }386 }
379 runtime.Gosched()387 runtime.Gosched()
388+ return true
380 }389 }
381 390
382 // notifyAck reports that the client acknowledged an output state, releasing391 // notifyAck reports that the client acknowledged an output state, releasing
M internal/usecase/daemon/render_coordinator_test.go +52 -0
@@ -1558,4 +1558,56 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
15581558 requireNoInvalidation(t, invs)
15591559 requireNoCoordinatorOutputFrame(t, sends)
15601560 })
1561+
1562+ t.Run("request reports immediate completion and async schedule acceptance", func(t *testing.T) {
1563+ t.Run("immediate reset invalidation", func(t *testing.T) {
1564+ d, sess, ac, _, _, invs := newResizeFixture(t)
1565+
1566+ require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true))
1567+ inv := awaitInvalidation(t, invs)
1568+ require.True(t, inv.reset)
1569+ })
1570+
1571+ t.Run("superseded immediate transaction", func(t *testing.T) {
1572+ resizeStarted := make(chan struct{})
1573+ releaseResize := make(chan struct{})
1574+ pty := &transactionalResizePTY{onResize: func() {
1575+ close(resizeStarted)
1576+ <-releaseResize
1577+ }}
1578+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
1579+ clk := newCoordinatorMockClock(t, 2)
1580+ d.clock = clk.clock
1581+ invs := make(chan renderInvalidation, 1)
1582+ rc := newRenderCoordinator(renderCoordinatorOptions{
1583+ clock: clk.clock,
1584+ wake: func(renderWake) {},
1585+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
1586+ })
1587+ rc.attach(ac)
1588+ sess.installRenderCoordinator(rc)
1589+
1590+ result := make(chan bool, 1)
1591+ go func() {
1592+ result <- d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true)
1593+ }()
1594+ <-resizeStarted
1595+ require.NotZero(t, rc.recordResizeRequest(domain.Size{Cols: 140, Rows: 30}, ac))
1596+ close(releaseResize)
1597+
1598+ require.False(t, <-result)
1599+ requireNoInvalidation(t, invs)
1600+ })
1601+
1602+ t.Run("async schedule and teardown rejection", func(t *testing.T) {
1603+ d, sess, ac, _, clk, _ := newResizeFixture(t)
1604+
1605+ require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, false))
1606+ awaitCoordinatorScheduledTimer(t, clk)
1607+
1608+ // A torn-down coordinator cannot accept a stale attachment's schedule.
1609+ sess.renderCoordinator().noteSessionTeardown()
1610+ require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 140, Rows: 30}, false))
1611+ })
1612+ })
15611613 }
@@ -1558,4 +1558,56 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
1558 requireNoInvalidation(t, invs)1558 requireNoInvalidation(t, invs)
1559 requireNoCoordinatorOutputFrame(t, sends)1559 requireNoCoordinatorOutputFrame(t, sends)
1560 })1560 })
1561+
1562+ t.Run("request reports immediate completion and async schedule acceptance", func(t *testing.T) {
1563+ t.Run("immediate reset invalidation", func(t *testing.T) {
1564+ d, sess, ac, _, _, invs := newResizeFixture(t)
1565+
1566+ require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true))
1567+ inv := awaitInvalidation(t, invs)
1568+ require.True(t, inv.reset)
1569+ })
1570+
1571+ t.Run("superseded immediate transaction", func(t *testing.T) {
1572+ resizeStarted := make(chan struct{})
1573+ releaseResize := make(chan struct{})
1574+ pty := &transactionalResizePTY{onResize: func() {
1575+ close(resizeStarted)
1576+ <-releaseResize
1577+ }}
1578+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
1579+ clk := newCoordinatorMockClock(t, 2)
1580+ d.clock = clk.clock
1581+ invs := make(chan renderInvalidation, 1)
1582+ rc := newRenderCoordinator(renderCoordinatorOptions{
1583+ clock: clk.clock,
1584+ wake: func(renderWake) {},
1585+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
1586+ })
1587+ rc.attach(ac)
1588+ sess.installRenderCoordinator(rc)
1589+
1590+ result := make(chan bool, 1)
1591+ go func() {
1592+ result <- d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true)
1593+ }()
1594+ <-resizeStarted
1595+ require.NotZero(t, rc.recordResizeRequest(domain.Size{Cols: 140, Rows: 30}, ac))
1596+ close(releaseResize)
1597+
1598+ require.False(t, <-result)
1599+ requireNoInvalidation(t, invs)
1600+ })
1601+
1602+ t.Run("async schedule and teardown rejection", func(t *testing.T) {
1603+ d, sess, ac, _, clk, _ := newResizeFixture(t)
1604+
1605+ require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, false))
1606+ awaitCoordinatorScheduledTimer(t, clk)
1607+
1608+ // A torn-down coordinator cannot accept a stale attachment's schedule.
1609+ sess.renderCoordinator().noteSessionTeardown()
1610+ require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 140, Rows: 30}, false))
1611+ })
1612+ })
1561 }1613 }
M internal/usecase/daemon/render_test.go +134 -0
@@ -55,6 +55,43 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
5555 require.True(t, paletteVisible, "palette must remain composed above copy mode")
5656 }
5757
58+func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
59+ for _, tc := range []struct {
60+ name string
61+ clientSize domain.Size
62+ }{
63+ {name: "matching outer size", clientSize: domain.Size{Cols: 80, Rows: 25}},
64+ {name: "different outer size", clientSize: domain.Size{Cols: 100, Rows: 40}},
65+ } {
66+ t.Run(tc.name, func(t *testing.T) {
67+ pty, releasePTY := newBlockingPTY(t)
68+ defer releasePTY()
69+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
70+ ac.sendMu.Lock()
71+ ac.size = tc.clientSize
72+ ac.sendMu.Unlock()
73+
74+ // A retained visible popup still needs activation warmup. When the
75+ // outer terminal also changes, its completed transaction must cover
76+ // that popup rather than emitting a second reset-producing frame.
77+ floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8})
78+ installTestFloating(sess.activeTab(), floating, true)
79+
80+ d.firstPaint(sess, ac, tc.clientSize)
81+
82+ frame := awaitFrame(t, sends, ports.MsgOutput)
83+ output, err := ports.UnmarshalOutput(frame.Payload)
84+ require.NoError(t, err)
85+ require.Zero(t, output.BaseStateNum, "first paint must be a mandatory reset")
86+ select {
87+ case extra := <-sends:
88+ t.Fatalf("first paint emitted duplicate frame after floating activation resize: %#v", extra)
89+ default:
90+ }
91+ })
92+ }
93+}
94+
5895 func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
5996 p, release := newBlockingPTY(t)
6097 defer release()
@@ -254,6 +291,103 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
254291 })
255292 }
256293
294+func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T) {
295+ for _, tc := range []struct {
296+ name string
297+ window uint64
298+ exitBeforeACK bool
299+ exitSuffix string
300+ overlay bool
301+ }{
302+ {name: "datagram output window (1): exit waits for ACK", window: 1},
303+ {name: "datagram output window (1): exit and prompt arrive before ACK", window: 1, exitBeforeACK: true, exitSuffix: "PROMPT> "},
304+ {name: "local default output window (8): exit waits for ACK", window: maxUnackedOutputStates},
305+ {name: "local default output window (8): exit and prompt arrive before ACK", window: maxUnackedOutputStates, exitBeforeACK: true, exitSuffix: "PROMPT> "},
306+ {name: "local default output window (8): palette overlay with bars", window: maxUnackedOutputStates, overlay: true},
307+ } {
308+ t.Run(tc.name, func(t *testing.T) {
309+ pty, steps, processed := newChannelPTY(t)
310+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
311+ // Datagram clients use a one-frame window; local clients retain the
312+ // default eight-frame window. Both ACK orderings stay deterministic.
313+ ac.output.maxOutstanding = tc.window
314+ clock := newCoordinatorMockClock(t, 8)
315+ d.clock = clock.clock
316+ rc := d.attachCoordinator(sess, nil, ac, true)
317+ client := vt.NewScreen(80, 25)
318+ pane := sess.tabs[0].focusedPane()
319+
320+ d.sessWg.Add(1)
321+ go d.ptyReader(sess, sess.tabs[0], pane)
322+ t.Cleanup(func() {
323+ steps <- channelPTYStep{err: io.EOF}
324+ d.sessWg.Wait()
325+ })
326+
327+ replay := func(data []byte, deadline time.Duration) ports.Output {
328+ t.Helper()
329+ steps <- channelPTYStep{data: data}
330+ awaitPTYReadProcessed(t, processed)
331+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), deadline)
332+ return mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
333+ }
334+ acknowledge := func(out ports.Output) {
335+ ac.ackOutputState(out.NewStateNum)
336+ rc.notifyAck()
337+ }
338+
339+ if tc.overlay {
340+ d.enterPalette(sess, ac)
341+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
342+ acknowledge(mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)))
343+ }
344+ acknowledge(replay([]byte("PRIMARY"), minOutputRenderDeadline))
345+ primary := client.Frame.Clone()
346+
347+ // The reported PTY stream is deliberately split into enter, body, and
348+ // exit reads. MATRIX distinguishes alternate cells in the replayed
349+ // terminal model from all primary, chrome, and overlay cells.
350+ acknowledge(replay([]byte("\x1b[?1049h"), minOutputRenderDeadline))
351+ alternate := replay([]byte(strings.Repeat("MATRIX", 300)), minOutputRenderDeadline)
352+ pane.mu.Lock()
353+ require.Contains(t, strings.Join(frameRows(pane.screen.Frame), "\n"), "MATRIX", "fixture must make alternate cells visible to the production renderer")
354+ pane.mu.Unlock()
355+
356+ exitData := []byte("\x1b[?1049l" + tc.exitSuffix)
357+ var exit ports.Output
358+ if tc.exitBeforeACK {
359+ steps <- channelPTYStep{data: exitData}
360+ awaitPTYReadProcessed(t, processed)
361+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
362+ if tc.window == 1 {
363+ requireNoCoordinatorOutputFrame(t, sends)
364+ acknowledge(alternate)
365+ exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
366+ } else {
367+ exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
368+ require.Equal(t, alternate.NewStateNum, exit.BaseStateNum,
369+ "local exit must be rendered before the alternate-frame ACK")
370+ acknowledge(alternate)
371+ }
372+ } else {
373+ acknowledge(alternate)
374+ steps <- channelPTYStep{data: exitData}
375+ awaitPTYReadProcessed(t, processed)
376+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
377+ exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
378+ }
379+ require.NotEmpty(t, exit.Data, "the first post-exit output must actively remove alternate cells")
380+ rendered := strings.Join(frameRows(client.Frame), "\n")
381+ require.NotContains(t, rendered, "MATRIX", "the first post-exit frame must not retain alternate cells")
382+ if tc.exitSuffix == "" {
383+ require.Equal(t, frameRows(primary), frameRows(client.Frame), "the first post-exit output must restore the primary screen")
384+ } else {
385+ require.Contains(t, rendered, tc.exitSuffix, "bytes immediately after DEC 1049 exit must appear in the first post-exit frame")
386+ }
387+ })
388+ }
389+}
390+
257391 func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {
258392 t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {
259393 pty, steps, processed := newChannelPTY(t)
@@ -55,6 +55,43 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
55 require.True(t, paletteVisible, "palette must remain composed above copy mode")55 require.True(t, paletteVisible, "palette must remain composed above copy mode")
56 }56 }
57 57
58+func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
59+ for _, tc := range []struct {
60+ name string
61+ clientSize domain.Size
62+ }{
63+ {name: "matching outer size", clientSize: domain.Size{Cols: 80, Rows: 25}},
64+ {name: "different outer size", clientSize: domain.Size{Cols: 100, Rows: 40}},
65+ } {
66+ t.Run(tc.name, func(t *testing.T) {
67+ pty, releasePTY := newBlockingPTY(t)
68+ defer releasePTY()
69+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
70+ ac.sendMu.Lock()
71+ ac.size = tc.clientSize
72+ ac.sendMu.Unlock()
73+
74+ // A retained visible popup still needs activation warmup. When the
75+ // outer terminal also changes, its completed transaction must cover
76+ // that popup rather than emitting a second reset-producing frame.
77+ floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8})
78+ installTestFloating(sess.activeTab(), floating, true)
79+
80+ d.firstPaint(sess, ac, tc.clientSize)
81+
82+ frame := awaitFrame(t, sends, ports.MsgOutput)
83+ output, err := ports.UnmarshalOutput(frame.Payload)
84+ require.NoError(t, err)
85+ require.Zero(t, output.BaseStateNum, "first paint must be a mandatory reset")
86+ select {
87+ case extra := <-sends:
88+ t.Fatalf("first paint emitted duplicate frame after floating activation resize: %#v", extra)
89+ default:
90+ }
91+ })
92+ }
93+}
94+
58 func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {95 func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
59 p, release := newBlockingPTY(t)96 p, release := newBlockingPTY(t)
60 defer release()97 defer release()
@@ -254,6 +291,103 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
254 })291 })
255 }292 }
256 293
294+func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T) {
295+ for _, tc := range []struct {
296+ name string
297+ window uint64
298+ exitBeforeACK bool
299+ exitSuffix string
300+ overlay bool
301+ }{
302+ {name: "datagram output window (1): exit waits for ACK", window: 1},
303+ {name: "datagram output window (1): exit and prompt arrive before ACK", window: 1, exitBeforeACK: true, exitSuffix: "PROMPT> "},
304+ {name: "local default output window (8): exit waits for ACK", window: maxUnackedOutputStates},
305+ {name: "local default output window (8): exit and prompt arrive before ACK", window: maxUnackedOutputStates, exitBeforeACK: true, exitSuffix: "PROMPT> "},
306+ {name: "local default output window (8): palette overlay with bars", window: maxUnackedOutputStates, overlay: true},
307+ } {
308+ t.Run(tc.name, func(t *testing.T) {
309+ pty, steps, processed := newChannelPTY(t)
310+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
311+ // Datagram clients use a one-frame window; local clients retain the
312+ // default eight-frame window. Both ACK orderings stay deterministic.
313+ ac.output.maxOutstanding = tc.window
314+ clock := newCoordinatorMockClock(t, 8)
315+ d.clock = clock.clock
316+ rc := d.attachCoordinator(sess, nil, ac, true)
317+ client := vt.NewScreen(80, 25)
318+ pane := sess.tabs[0].focusedPane()
319+
320+ d.sessWg.Add(1)
321+ go d.ptyReader(sess, sess.tabs[0], pane)
322+ t.Cleanup(func() {
323+ steps <- channelPTYStep{err: io.EOF}
324+ d.sessWg.Wait()
325+ })
326+
327+ replay := func(data []byte, deadline time.Duration) ports.Output {
328+ t.Helper()
329+ steps <- channelPTYStep{data: data}
330+ awaitPTYReadProcessed(t, processed)
331+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), deadline)
332+ return mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
333+ }
334+ acknowledge := func(out ports.Output) {
335+ ac.ackOutputState(out.NewStateNum)
336+ rc.notifyAck()
337+ }
338+
339+ if tc.overlay {
340+ d.enterPalette(sess, ac)
341+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
342+ acknowledge(mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)))
343+ }
344+ acknowledge(replay([]byte("PRIMARY"), minOutputRenderDeadline))
345+ primary := client.Frame.Clone()
346+
347+ // The reported PTY stream is deliberately split into enter, body, and
348+ // exit reads. MATRIX distinguishes alternate cells in the replayed
349+ // terminal model from all primary, chrome, and overlay cells.
350+ acknowledge(replay([]byte("\x1b[?1049h"), minOutputRenderDeadline))
351+ alternate := replay([]byte(strings.Repeat("MATRIX", 300)), minOutputRenderDeadline)
352+ pane.mu.Lock()
353+ require.Contains(t, strings.Join(frameRows(pane.screen.Frame), "\n"), "MATRIX", "fixture must make alternate cells visible to the production renderer")
354+ pane.mu.Unlock()
355+
356+ exitData := []byte("\x1b[?1049l" + tc.exitSuffix)
357+ var exit ports.Output
358+ if tc.exitBeforeACK {
359+ steps <- channelPTYStep{data: exitData}
360+ awaitPTYReadProcessed(t, processed)
361+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
362+ if tc.window == 1 {
363+ requireNoCoordinatorOutputFrame(t, sends)
364+ acknowledge(alternate)
365+ exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
366+ } else {
367+ exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
368+ require.Equal(t, alternate.NewStateNum, exit.BaseStateNum,
369+ "local exit must be rendered before the alternate-frame ACK")
370+ acknowledge(alternate)
371+ }
372+ } else {
373+ acknowledge(alternate)
374+ steps <- channelPTYStep{data: exitData}
375+ awaitPTYReadProcessed(t, processed)
376+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
377+ exit = mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
378+ }
379+ require.NotEmpty(t, exit.Data, "the first post-exit output must actively remove alternate cells")
380+ rendered := strings.Join(frameRows(client.Frame), "\n")
381+ require.NotContains(t, rendered, "MATRIX", "the first post-exit frame must not retain alternate cells")
382+ if tc.exitSuffix == "" {
383+ require.Equal(t, frameRows(primary), frameRows(client.Frame), "the first post-exit output must restore the primary screen")
384+ } else {
385+ require.Contains(t, rendered, tc.exitSuffix, "bytes immediately after DEC 1049 exit must appear in the first post-exit frame")
386+ }
387+ })
388+ }
389+}
390+
257 func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {391 func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {
258 t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {392 t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {
259 pty, steps, processed := newChannelPTY(t)393 pty, steps, processed := newChannelPTY(t)
M internal/usecase/daemon/resume.go +5 -0
@@ -48,6 +48,11 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
4848 if !d.prepareParkAttachment(sess, ac) {
4949 return false
5050 }
51+ // A parked attachment has no session owner. Release pane snapshots before
52+ // publishing it in the daemon registry so headless pane/session teardown
53+ // cannot leave closed panes retained through its capture cache. This must
54+ // remain outside d.mu: sendMu is ordered before the daemon lock.
55+ ac.clearCaptureFrames()
5156 d.mu.Lock()
5257 if d.closing {
5358 d.mu.Unlock()
@@ -48,6 +48,11 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
48 if !d.prepareParkAttachment(sess, ac) {48 if !d.prepareParkAttachment(sess, ac) {
49 return false49 return false
50 }50 }
51+ // A parked attachment has no session owner. Release pane snapshots before
52+ // publishing it in the daemon registry so headless pane/session teardown
53+ // cannot leave closed panes retained through its capture cache. This must
54+ // remain outside d.mu: sendMu is ordered before the daemon lock.
55+ ac.clearCaptureFrames()
51 d.mu.Lock()56 d.mu.Lock()
52 if d.closing {57 if d.closing {
53 d.mu.Unlock()58 d.mu.Unlock()
M internal/usecase/daemon/resume_test.go +57 -0
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "errors"
5+ "strings"
56 "sync"
67 "testing"
78 "time"
@@ -10,6 +11,8 @@ import (
1011
1112 "github.com/bnema/vev/internal/domain"
1213 "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/layout"
15+ "github.com/bnema/vev/pkg/vt"
1316 )
1417
1518 type closeTrackingTransport struct {
@@ -296,6 +299,60 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
296299 require.Equal(t, first.NewStateNum, second.BaseStateNum)
297300 }
298301
302+func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
303+ pty, release := newBlockingPTY(t)
304+ defer release()
305+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
306+ oldTransport := &closeTrackingTransport{}
307+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
308+ require.NoError(t, err)
309+
310+ tb := sess.tabs[0]
311+ survivor := tb.panes["pane-1"]
312+ closed := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 23})
313+ tb.mu.Lock()
314+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
315+ tb.panes[closed.id] = closed
316+ d.applyLayoutLocked(tb)
317+ tb.mu.Unlock()
318+ survivor.screen.Write([]byte("survivor"))
319+ closed.screen.Write([]byte("closed"))
320+ d.paint(sess, ac, true)
321+
322+ ac.sendMu.Lock()
323+ require.Contains(t, ac.captureFrames, closed, "fixture must render and capture the pane before parking")
324+ ac.sendMu.Unlock()
325+ token := ac.resumeToken
326+ require.True(t, sess.detachIfCurrent(ac))
327+ require.True(t, d.parkAttachment(sess, ac))
328+
329+ // Headless close cannot find the parked attachment through sess.client.
330+ // Its capture must already have been released before the attachment parked.
331+ require.NoError(t, d.closePane(sess, tb, closed.id, nil, false))
332+ ac.sendMu.Lock()
333+ require.NotContains(t, ac.captureFrames, closed, "parked attachment must not retain a pane closed while headless")
334+ ac.sendMu.Unlock()
335+
336+ newTransport := &closeTrackingTransport{}
337+ resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, sess.name, token), newTransport)
338+ require.NoError(t, err)
339+ require.Same(t, sess, resumedSess)
340+ require.Same(t, ac, resumedAC)
341+ require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedAC))
342+ d.firstPaint(resumedSess, resumedAC, resumedAC.size)
343+
344+ sends := newTransport.Sends()
345+ require.Len(t, sends, 1)
346+ output, err := ports.UnmarshalOutput(sends[0].Payload)
347+ require.NoError(t, err)
348+ require.Zero(t, output.BaseStateNum, "resume must start with a complete frame")
349+ terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows)
350+ terminal.Write(output.Data)
351+ contents := strings.Join(frameRows(terminal.Frame), "\n")
352+ require.Contains(t, contents, "survivor", "resume first paint must contain current headless content")
353+ require.NotContains(t, contents, "closed", "resume first paint must not contain closed pane content")
354+}
355+
299356 func TestExplicitDetachDoesNotPark(t *testing.T) {
300357 pty, release := newBlockingPTY(t)
301358 defer release()
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "strings"
5 "sync"6 "sync"
6 "testing"7 "testing"
7 "time"8 "time"
@@ -10,6 +11,8 @@ import (
10 11
11 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/usecase/layout"
15+ "github.com/bnema/vev/pkg/vt"
13 )16 )
14 17
15 type closeTrackingTransport struct {18 type closeTrackingTransport struct {
@@ -296,6 +299,60 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
296 require.Equal(t, first.NewStateNum, second.BaseStateNum)299 require.Equal(t, first.NewStateNum, second.BaseStateNum)
297 }300 }
298 301
302+func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
303+ pty, release := newBlockingPTY(t)
304+ defer release()
305+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
306+ oldTransport := &closeTrackingTransport{}
307+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
308+ require.NoError(t, err)
309+
310+ tb := sess.tabs[0]
311+ survivor := tb.panes["pane-1"]
312+ closed := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 23})
313+ tb.mu.Lock()
314+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-1"}
315+ tb.panes[closed.id] = closed
316+ d.applyLayoutLocked(tb)
317+ tb.mu.Unlock()
318+ survivor.screen.Write([]byte("survivor"))
319+ closed.screen.Write([]byte("closed"))
320+ d.paint(sess, ac, true)
321+
322+ ac.sendMu.Lock()
323+ require.Contains(t, ac.captureFrames, closed, "fixture must render and capture the pane before parking")
324+ ac.sendMu.Unlock()
325+ token := ac.resumeToken
326+ require.True(t, sess.detachIfCurrent(ac))
327+ require.True(t, d.parkAttachment(sess, ac))
328+
329+ // Headless close cannot find the parked attachment through sess.client.
330+ // Its capture must already have been released before the attachment parked.
331+ require.NoError(t, d.closePane(sess, tb, closed.id, nil, false))
332+ ac.sendMu.Lock()
333+ require.NotContains(t, ac.captureFrames, closed, "parked attachment must not retain a pane closed while headless")
334+ ac.sendMu.Unlock()
335+
336+ newTransport := &closeTrackingTransport{}
337+ resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, sess.name, token), newTransport)
338+ require.NoError(t, err)
339+ require.Same(t, sess, resumedSess)
340+ require.Same(t, ac, resumedAC)
341+ require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedAC))
342+ d.firstPaint(resumedSess, resumedAC, resumedAC.size)
343+
344+ sends := newTransport.Sends()
345+ require.Len(t, sends, 1)
346+ output, err := ports.UnmarshalOutput(sends[0].Payload)
347+ require.NoError(t, err)
348+ require.Zero(t, output.BaseStateNum, "resume must start with a complete frame")
349+ terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows)
350+ terminal.Write(output.Data)
351+ contents := strings.Join(frameRows(terminal.Frame), "\n")
352+ require.Contains(t, contents, "survivor", "resume first paint must contain current headless content")
353+ require.NotContains(t, contents, "closed", "resume first paint must not contain closed pane content")
354+}
355+
299 func TestExplicitDetachDoesNotPark(t *testing.T) {356 func TestExplicitDetachDoesNotPark(t *testing.T) {
300 pty, release := newBlockingPTY(t)357 pty, release := newBlockingPTY(t)
301 defer release()358 defer release()
M internal/usecase/daemon/session.go +7 -3
@@ -632,14 +632,17 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
632632 markSnapshotDirty(sess)
633633
634634 d.clearDestroyedTabPreview(tb)
635+ tb.mu.Lock()
636+ panes := tb.panesSnapshot()
637+ tb.mu.Unlock()
638+ if ac != nil {
639+ ac.pruneCaptureFrames(panes...)
640+ }
635641 if tb.cancel != nil {
636642 tb.cancel()
637643 }
638644 d.teardownFloating(tb, ac)
639645 if rc := sess.renderCoordinator(); rc != nil {
640- tb.mu.Lock()
641- panes := tb.panesSnapshot()
642- tb.mu.Unlock()
643646 for _, p := range panes {
644647 rc.noteSyncPaneRemoved(p)
645648 }
@@ -742,6 +745,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
742745 d.unregisterPreview(ac)
743746 ac.clearPreviousSession()
744747 ac.setSession(nil)
748+ ac.clearCaptureFrames()
745749 }
746750
747751 // Prevent queued launches from entering Open, then cancel the parent
@@ -632,14 +632,17 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
632 markSnapshotDirty(sess)632 markSnapshotDirty(sess)
633 633
634 d.clearDestroyedTabPreview(tb)634 d.clearDestroyedTabPreview(tb)
635+ tb.mu.Lock()
636+ panes := tb.panesSnapshot()
637+ tb.mu.Unlock()
638+ if ac != nil {
639+ ac.pruneCaptureFrames(panes...)
640+ }
635 if tb.cancel != nil {641 if tb.cancel != nil {
636 tb.cancel()642 tb.cancel()
637 }643 }
638 d.teardownFloating(tb, ac)644 d.teardownFloating(tb, ac)
639 if rc := sess.renderCoordinator(); rc != nil {645 if rc := sess.renderCoordinator(); rc != nil {
640- tb.mu.Lock()
641- panes := tb.panesSnapshot()
642- tb.mu.Unlock()
643 for _, p := range panes {646 for _, p := range panes {
644 rc.noteSyncPaneRemoved(p)647 rc.noteSyncPaneRemoved(p)
645 }648 }
@@ -742,6 +745,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
742 d.unregisterPreview(ac)745 d.unregisterPreview(ac)
743 ac.clearPreviousSession()746 ac.clearPreviousSession()
744 ac.setSession(nil)747 ac.setSession(nil)
748+ ac.clearCaptureFrames()
745 }749 }
746 750
747 // Prevent queued launches from entering Open, then cancel the parent751 // Prevent queued launches from entering Open, then cancel the parent
M internal/usecase/daemon/status_test.go +47 -0
@@ -16,6 +16,7 @@ import (
1616 "github.com/bnema/vev/internal/usecase/command"
1717 "github.com/bnema/vev/internal/usecase/layout"
1818 "github.com/bnema/vev/internal/usecase/palette"
19+ "github.com/bnema/vev/internal/usecase/picker"
1920 themeui "github.com/bnema/vev/internal/usecase/theme"
2021 "github.com/bnema/vev/internal/usecase/ui"
2122 "github.com/bnema/vev/pkg/renderer"
@@ -1035,6 +1036,52 @@ func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
10351036 require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name})
10361037 }
10371038
1039+func TestCaptureRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) {
1040+ _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
1041+ capture := func(bars barState) capturedRenderState {
1042+ t.Helper()
1043+ state, ok := captureRenderState(sess, ac, bars, capturedOverlayRenderState{}, picker.Preview{}, domain.FloatingConfig{}, false, damageCaptureConsume)
1044+ require.True(t, ok)
1045+ return *state
1046+ }
1047+ draw := func(state capturedRenderState) string {
1048+ t.Helper()
1049+ row := make([]renderer.Cell, 32)
1050+ drawStatusBarState(row, state.bars, resolveThemeStyles(nil))
1051+ return rowText(row)
1052+ }
1053+ normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "vty"}, {name: "misc"}}}
1054+ ac.sendMu.Lock()
1055+ defer ac.sendMu.Unlock()
1056+
1057+ // An empty but non-nil list is JRS contextual mode, not normal mode.
1058+ emptyContextual := capture(barState{status: normal.status, mru: normal.mru, rankedRecent: []rankedRecent{}})
1059+ require.NotNil(t, emptyContextual.bars.rankedRecent)
1060+ require.Empty(t, emptyContextual.bars.rankedRecent)
1061+ emptyRow := draw(emptyContextual)
1062+ require.NotContains(t, emptyRow, "vty")
1063+ require.NotContains(t, emptyRow, "misc")
1064+
1065+ // A later normal frame must clear contextual mode even after scratch reuse.
1066+ normalAfterEmpty := capture(normal)
1067+ require.Nil(t, normalAfterEmpty.bars.rankedRecent)
1068+ normalRow := draw(normalAfterEmpty)
1069+ require.Contains(t, normalRow, "vty")
1070+ require.Contains(t, normalRow, "misc")
1071+
1072+ // A populated contextual list reuses the same scratch and remains distinct.
1073+ populatedContextual := capture(barState{status: normal.status, mru: normal.mru, rankedRecent: []rankedRecent{{rank: 1, name: "jrs"}}})
1074+ require.NotNil(t, populatedContextual.bars.rankedRecent)
1075+ require.Equal(t, []rankedRecent{{rank: 1, name: "jrs"}}, populatedContextual.bars.rankedRecent)
1076+ populatedRow := draw(populatedContextual)
1077+ require.Contains(t, populatedRow, "1:jrs")
1078+ require.NotContains(t, populatedRow, "vty")
1079+
1080+ normalAfterPopulated := capture(normal)
1081+ require.Nil(t, normalAfterPopulated.bars.rankedRecent)
1082+ require.Contains(t, draw(normalAfterPopulated), "vty")
1083+}
1084+
10381085 func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
10391086 state := barState{
10401087 status: statusSnapshot{session: "cur"},
@@ -16,6 +16,7 @@ import (
16 "github.com/bnema/vev/internal/usecase/command"16 "github.com/bnema/vev/internal/usecase/command"
17 "github.com/bnema/vev/internal/usecase/layout"17 "github.com/bnema/vev/internal/usecase/layout"
18 "github.com/bnema/vev/internal/usecase/palette"18 "github.com/bnema/vev/internal/usecase/palette"
19+ "github.com/bnema/vev/internal/usecase/picker"
19 themeui "github.com/bnema/vev/internal/usecase/theme"20 themeui "github.com/bnema/vev/internal/usecase/theme"
20 "github.com/bnema/vev/internal/usecase/ui"21 "github.com/bnema/vev/internal/usecase/ui"
21 "github.com/bnema/vev/pkg/renderer"22 "github.com/bnema/vev/pkg/renderer"
@@ -1035,6 +1036,52 @@ func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
1035 require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name})1036 require.Equal(t, []string{"alpha", "zeta"}, []string{state.mru[0].name, state.mru[1].name})
1036 }1037 }
1037 1038
1039+func TestCaptureRenderStatePreservesContextualMRUModeThroughScratchReuse(t *testing.T) {
1040+ _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
1041+ capture := func(bars barState) capturedRenderState {
1042+ t.Helper()
1043+ state, ok := captureRenderState(sess, ac, bars, capturedOverlayRenderState{}, picker.Preview{}, domain.FloatingConfig{}, false, damageCaptureConsume)
1044+ require.True(t, ok)
1045+ return *state
1046+ }
1047+ draw := func(state capturedRenderState) string {
1048+ t.Helper()
1049+ row := make([]renderer.Cell, 32)
1050+ drawStatusBarState(row, state.bars, resolveThemeStyles(nil))
1051+ return rowText(row)
1052+ }
1053+ normal := barState{status: statusSnapshot{session: "cur"}, mru: []recentSession{{name: "vty"}, {name: "misc"}}}
1054+ ac.sendMu.Lock()
1055+ defer ac.sendMu.Unlock()
1056+
1057+ // An empty but non-nil list is JRS contextual mode, not normal mode.
1058+ emptyContextual := capture(barState{status: normal.status, mru: normal.mru, rankedRecent: []rankedRecent{}})
1059+ require.NotNil(t, emptyContextual.bars.rankedRecent)
1060+ require.Empty(t, emptyContextual.bars.rankedRecent)
1061+ emptyRow := draw(emptyContextual)
1062+ require.NotContains(t, emptyRow, "vty")
1063+ require.NotContains(t, emptyRow, "misc")
1064+
1065+ // A later normal frame must clear contextual mode even after scratch reuse.
1066+ normalAfterEmpty := capture(normal)
1067+ require.Nil(t, normalAfterEmpty.bars.rankedRecent)
1068+ normalRow := draw(normalAfterEmpty)
1069+ require.Contains(t, normalRow, "vty")
1070+ require.Contains(t, normalRow, "misc")
1071+
1072+ // A populated contextual list reuses the same scratch and remains distinct.
1073+ populatedContextual := capture(barState{status: normal.status, mru: normal.mru, rankedRecent: []rankedRecent{{rank: 1, name: "jrs"}}})
1074+ require.NotNil(t, populatedContextual.bars.rankedRecent)
1075+ require.Equal(t, []rankedRecent{{rank: 1, name: "jrs"}}, populatedContextual.bars.rankedRecent)
1076+ populatedRow := draw(populatedContextual)
1077+ require.Contains(t, populatedRow, "1:jrs")
1078+ require.NotContains(t, populatedRow, "vty")
1079+
1080+ normalAfterPopulated := capture(normal)
1081+ require.Nil(t, normalAfterPopulated.bars.rankedRecent)
1082+ require.Contains(t, draw(normalAfterPopulated), "vty")
1083+}
1084+
1038 func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {1085 func TestStatusBarRendersMRUNamesEphemeralAndInlineBell(t *testing.T) {
1039 state := barState{1086 state := barState{
1040 status: statusSnapshot{session: "cur"},1087 status: statusSnapshot{session: "cur"},
M internal/usecase/daemon/transactional_resize.go +23 -16
@@ -165,25 +165,25 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan
165165 d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
166166 }
167167
168-func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) {
168+func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) bool {
169169 rc := sess.renderCoordinator()
170170 if rc == nil {
171- return
171+ return false
172172 }
173173 snap := rc.resizeSnapshot()
174174 if !rc.resizeCurrent(epoch, ac, false) {
175- return
175+ return false
176176 }
177177 d.exitCopyMode(ac)
178178 plan := d.prepareResize(sess, snap.size)
179179 if !rc.resizeCurrent(epoch, ac, false) {
180- return
180+ return false
181181 }
182182 if !d.applyResize(&plan, func() bool { return rc.resizeCurrent(epoch, ac, false) }) {
183- return
183+ return false
184184 }
185185 if !rc.resizeCurrent(epoch, ac, true) {
186- return
186+ return false
187187 }
188188 d.commitResize(sess, ac, plan)
189189 failed := make([]resizeMember, 0, len(plan.members))
@@ -198,11 +198,14 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u
198198 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
199199 // A successful transaction publishes exactly one full S2 frame. The
200200 // coordinator is the only emission route and stale epochs never reach it.
201- rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
201+ if !rc.invalidateForAttachmentAtResizeEpoch(ac, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
202+ return false
203+ }
202204 // The resize debounce has already elapsed. Consume this sticky reset now;
203205 // fire preserves ACK and synchronized-output gates rather than scheduling a
204206 // second urgent deadline.
205207 rc.fireCurrent(false)
208+ return true
206209 }
207210
208211 // retryResizeMembers retries only members which failed the committed epoch.
@@ -244,10 +247,14 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin
244247 }
245248 }
246249
247-func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) {
248- d.observeRuntime(ports.RuntimeResizeRequested, 0, sess != nil && size.Valid())
249- if sess == nil || !size.Valid() {
250- return
250+// requestTransactionalResize reports reset invalidation completion for immediate
251+// attached requests, and coordinator schedule acceptance for async requests.
252+// Headless requests have no reset invalidation and report geometry completion.
253+func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {
254+ valid := sess != nil && size.Valid()
255+ d.observeRuntime(ports.RuntimeResizeRequested, 0, valid)
256+ if !valid {
257+ return false
251258 }
252259 if ac == nil {
253260 // Headless geometry has no coordinator/transport to coalesce, but keeps
@@ -255,15 +262,15 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s
255262 plan := d.prepareResize(sess, size)
256263 d.applyResize(&plan)
257264 d.commitResize(sess, nil, plan)
258- return
265+ return true
259266 }
260267 rc := d.attachCoordinator(sess, nil, ac, true)
261268 if immediate {
262269 epoch := rc.recordResizeRequest(size, ac)
263- if epoch != 0 {
264- d.runResizeTransaction(sess, ac, epoch)
270+ if epoch == 0 {
271+ return false
265272 }
266- return
273+ return d.runResizeTransaction(sess, ac, epoch)
267274 }
268- rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) })
275+ return rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) }) != 0
269276 }
@@ -165,25 +165,25 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan
165 d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)165 d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
166 }166 }
167 167
168-func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) {168+func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) bool {
169 rc := sess.renderCoordinator()169 rc := sess.renderCoordinator()
170 if rc == nil {170 if rc == nil {
171- return171+ return false
172 }172 }
173 snap := rc.resizeSnapshot()173 snap := rc.resizeSnapshot()
174 if !rc.resizeCurrent(epoch, ac, false) {174 if !rc.resizeCurrent(epoch, ac, false) {
175- return175+ return false
176 }176 }
177 d.exitCopyMode(ac)177 d.exitCopyMode(ac)
178 plan := d.prepareResize(sess, snap.size)178 plan := d.prepareResize(sess, snap.size)
179 if !rc.resizeCurrent(epoch, ac, false) {179 if !rc.resizeCurrent(epoch, ac, false) {
180- return180+ return false
181 }181 }
182 if !d.applyResize(&plan, func() bool { return rc.resizeCurrent(epoch, ac, false) }) {182 if !d.applyResize(&plan, func() bool { return rc.resizeCurrent(epoch, ac, false) }) {
183- return183+ return false
184 }184 }
185 if !rc.resizeCurrent(epoch, ac, true) {185 if !rc.resizeCurrent(epoch, ac, true) {
186- return186+ return false
187 }187 }
188 d.commitResize(sess, ac, plan)188 d.commitResize(sess, ac, plan)
189 failed := make([]resizeMember, 0, len(plan.members))189 failed := make([]resizeMember, 0, len(plan.members))
@@ -198,11 +198,14 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u
198 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)198 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
199 // A successful transaction publishes exactly one full S2 frame. The199 // A successful transaction publishes exactly one full S2 frame. The
200 // coordinator is the only emission route and stale epochs never reach it.200 // coordinator is the only emission route and stale epochs never reach it.
201- rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})201+ if !rc.invalidateForAttachmentAtResizeEpoch(ac, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
202+ return false
203+ }
202 // The resize debounce has already elapsed. Consume this sticky reset now;204 // The resize debounce has already elapsed. Consume this sticky reset now;
203 // fire preserves ACK and synchronized-output gates rather than scheduling a205 // fire preserves ACK and synchronized-output gates rather than scheduling a
204 // second urgent deadline.206 // second urgent deadline.
205 rc.fireCurrent(false)207 rc.fireCurrent(false)
208+ return true
206 }209 }
207 210
208 // retryResizeMembers retries only members which failed the committed epoch.211 // retryResizeMembers retries only members which failed the committed epoch.
@@ -244,10 +247,14 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin
244 }247 }
245 }248 }
246 249
247-func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) {250+// requestTransactionalResize reports reset invalidation completion for immediate
248- d.observeRuntime(ports.RuntimeResizeRequested, 0, sess != nil && size.Valid())251+// attached requests, and coordinator schedule acceptance for async requests.
249- if sess == nil || !size.Valid() {252+// Headless requests have no reset invalidation and report geometry completion.
250- return253+func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {
254+ valid := sess != nil && size.Valid()
255+ d.observeRuntime(ports.RuntimeResizeRequested, 0, valid)
256+ if !valid {
257+ return false
251 }258 }
252 if ac == nil {259 if ac == nil {
253 // Headless geometry has no coordinator/transport to coalesce, but keeps260 // Headless geometry has no coordinator/transport to coalesce, but keeps
@@ -255,15 +262,15 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s
255 plan := d.prepareResize(sess, size)262 plan := d.prepareResize(sess, size)
256 d.applyResize(&plan)263 d.applyResize(&plan)
257 d.commitResize(sess, nil, plan)264 d.commitResize(sess, nil, plan)
258- return265+ return true
259 }266 }
260 rc := d.attachCoordinator(sess, nil, ac, true)267 rc := d.attachCoordinator(sess, nil, ac, true)
261 if immediate {268 if immediate {
262 epoch := rc.recordResizeRequest(size, ac)269 epoch := rc.recordResizeRequest(size, ac)
263- if epoch != 0 {270+ if epoch == 0 {
264- d.runResizeTransaction(sess, ac, epoch)271+ return false
265 }272 }
266- return273+ return d.runResizeTransaction(sess, ac, epoch)
267 }274 }
268- rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) })275+ return rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) }) != 0
269 }276 }
M pkg/vt/screen.go +13 -7
@@ -92,12 +92,13 @@ type Screen struct {
9292 defaultColorsKnown bool
9393 terminalTitle string
9494
95- damage []renderer.Damage
96- damageGeneration uint64
97- damageSaturated bool
98- escapeBuf []byte
99- csiScratch []int
100- sgrScratch []int
95+ damage []renderer.Damage
96+ damageGeneration uint64
97+ damageSaturated bool
98+ damageFullRedrawSticky bool
99+ escapeBuf []byte
100+ csiScratch []int
101+ sgrScratch []int
101102
102103 scrollTop int
103104 scrollBottom int
@@ -191,6 +192,7 @@ func (s *Screen) Damage() []renderer.Damage { return s.damage }
191192 func (s *Screen) ClearDamage() {
192193 s.damage = s.damage[:0]
193194 s.damageSaturated = false
195+ s.damageFullRedrawSticky = false
194196 }
195197
196198 // CaptureDamage snapshots pending damage without consuming it.
@@ -652,7 +654,7 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
652654
653655 func (s *Screen) record(d renderer.Damage) {
654656 s.damageGeneration++
655- if s.damageSaturated {
657+ if s.damageSaturated || s.damageFullRedrawSticky {
656658 return
657659 }
658660 if len(s.damage) >= maxPendingDamage {
@@ -679,6 +681,10 @@ func (s *Screen) record(d renderer.Damage) {
679681 func (s *Screen) fullRedraw() {
680682 s.damageGeneration++
681683 s.damage = []renderer.Damage{renderer.FullRedraw()}
684+ // A structural frame replacement (such as DEC 1049 exit) cannot be
685+ // represented by the following text damage alone. Retain the redraw until
686+ // the render owner acknowledges this screen generation.
687+ s.damageFullRedrawSticky = true
682688 }
683689
684690 func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
@@ -92,12 +92,13 @@ type Screen struct {
92 defaultColorsKnown bool92 defaultColorsKnown bool
93 terminalTitle string93 terminalTitle string
94 94
95- damage []renderer.Damage95+ damage []renderer.Damage
96- damageGeneration uint6496+ damageGeneration uint64
97- damageSaturated bool97+ damageSaturated bool
98- escapeBuf []byte98+ damageFullRedrawSticky bool
99- csiScratch []int99+ escapeBuf []byte
100- sgrScratch []int100+ csiScratch []int
101+ sgrScratch []int
101 102
102 scrollTop int103 scrollTop int
103 scrollBottom int104 scrollBottom int
@@ -191,6 +192,7 @@ func (s *Screen) Damage() []renderer.Damage { return s.damage }
191 func (s *Screen) ClearDamage() {192 func (s *Screen) ClearDamage() {
192 s.damage = s.damage[:0]193 s.damage = s.damage[:0]
193 s.damageSaturated = false194 s.damageSaturated = false
195+ s.damageFullRedrawSticky = false
194 }196 }
195 197
196 // CaptureDamage snapshots pending damage without consuming it.198 // CaptureDamage snapshots pending damage without consuming it.
@@ -652,7 +654,7 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
652 654
653 func (s *Screen) record(d renderer.Damage) {655 func (s *Screen) record(d renderer.Damage) {
654 s.damageGeneration++656 s.damageGeneration++
655- if s.damageSaturated {657+ if s.damageSaturated || s.damageFullRedrawSticky {
656 return658 return
657 }659 }
658 if len(s.damage) >= maxPendingDamage {660 if len(s.damage) >= maxPendingDamage {
@@ -679,6 +681,10 @@ func (s *Screen) record(d renderer.Damage) {
679 func (s *Screen) fullRedraw() {681 func (s *Screen) fullRedraw() {
680 s.damageGeneration++682 s.damageGeneration++
681 s.damage = []renderer.Damage{renderer.FullRedraw()}683 s.damage = []renderer.Damage{renderer.FullRedraw()}
684+ // A structural frame replacement (such as DEC 1049 exit) cannot be
685+ // represented by the following text damage alone. Retain the redraw until
686+ // the render owner acknowledges this screen generation.
687+ s.damageFullRedrawSticky = true
682 }688 }
683 689
684 func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {690 func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close