detached 5fcde4c

Merge pull request #66 from bnema/feature/per-tab-floating-pane

feat(daemon): add per-tab floating terminals
commit
5fcde4c24b61c299ff617edf52b235f43c910533
author
bnema · · 2026-07-10 18:24 UTC
committer
GitHub · · 2026-07-10 18:24 UTC
merge of
3dbb3f2 + 4e7c62c · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
50 files · +3437 -203
M README.md +15 -0
@@ -49,8 +49,14 @@ vev reads `$XDG_CONFIG_HOME/vev/config`, or `~/.config/vev/config` when `XDG_CON
4949 # Theme: auto follows the client; dark/light force built-in palettes.
5050 theme = auto
5151
52+# One prewarmed floating terminal per tab. An empty command uses the normal shell.
53+floating.command =
54+floating.width = 80%
55+floating.height = 80%
56+
5257 # Rebindable actions. Leave a line out to keep its built-in binding.
5358 open-palette = alt+space
59+toggle-floating-pane = alt+f
5460 jump-attention = alt+a
5561 focus-pane-left = alt+h
5662 focus-pane-right = alt+l
@@ -97,6 +103,7 @@ code.back-session = BSK
97103 code.forward-session = FSK
98104 code.session-picker = SSP
99105 code.visual-mode = VIS
106+code.toggle-floating-pane = FLT
100107 code.rename-session = RNS
101108 code.detach = DET
102109 ```
@@ -105,6 +112,8 @@ Configuring an action replaces all of its built-in aliases. In the example above
105112
106113 Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit key specs support `alt+1` through `alt+9`, not `alt+0`. `jump-attention` first opens the oldest attention tab in the current session; if none exists, it opens the oldest attention tab in another session. Invalid entries are logged as warnings and skipped where possible; duplicate config keys use the last value, binding conflicts keep the later action's defaults, and command-code conflicts drop the conflicting override.
107114
115+`floating.command` runs through the normal shell; leave it empty to launch that shell directly. `floating.width` and `floating.height` require exact percentages from `1%` through `100%`; invalid values log a warning and leave the last accepted value (or the `80%` default) intact. Reloading does not relaunch or immediately alter an installed floating terminal: a changed command is used on its next launch, while changed dimensions take effect when that terminal is next shown or resized. Only configuration—not floating runtime state—is restored after a daemon restart; restored tabs stay cold until first visited.
116+
108117 ### Bar right anchors
109118
110119 The top and bottom bar right anchors run configurable commands on the daemon host. By default, `bar.top-right = vev-bar-top-right` prints CPU, memory, then hour text (for example `C 12% M 48% 09:41`), and `bar.bottom-right = vev-bar-bottom-right` prints compact git state for the focused pane's `VEV_PANE_CWD`. Set either value empty to disable that anchor, or set it to another command to replace it.
@@ -145,6 +154,7 @@ All bindings use Alt directly; there is no prefix key.
145154 | Key | Action |
146155 |---|---|
147156 | Alt+Space | open the command palette |
157+| Alt+f | show or hide the current tab's floating terminal |
148158 | Alt+1 .. Alt+9 | switch to tab by number |
149159 | Alt+h/j/k/l | focus pane left/down/up/right |
150160 | Alt+Arrow | focus pane by direction |
@@ -172,6 +182,7 @@ Successful commands are promoted near the top the next time the palette opens.
172182 | BSK / FSK | move back / forward through recent sessions |
173183 | SSP | open session picker |
174184 | VIS | enter visual mode |
185+| FLT | show or hide the current tab's floating terminal |
175186 | RNS | rename session |
176187 | RNT | rename current tab |
177188 | DET | detach |
@@ -193,6 +204,10 @@ Open it with `SSP` in the palette. It lists sessions and tabs, previews the sele
193204
194205 Tabs hold pane layouts. Panes can be split left/right/up/down, stacked, focused by direction, and closed from the command palette. Clicking a pane focuses it.
195206
207+Each tab also owns one independent floating terminal. It prewarms asynchronously on the tab's first visit, opens centered at the configured percentage of the pane-content area, and dims normal pane content while leaving status bars and popup chrome crisp. Hiding it retains the process, screen, and scrollback; switching tabs restores each tab's own visible or hidden state. Process exit clears the slot so the next open recreates it. The title combines the foreground process and OSC terminal title as `ProcessName: ProcessTerminalTitle`, falling back to either available title and then the configured command or shell.
208+
209+While visible, the floating terminal receives ordinary input, bracketed paste, mouse input, clipboard-path injection, and scrollback/copy mode. Global vev bindings remain active, and a bare Esc is passed to the floating application. `Alt+f` and `FLT` toggle visibility; there is no separate floating-pane close command.
210+
196211 The top bar shows numbered tabs. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`.
197212
198213 ## Scrollback mode and copy
M internal/adapters/config/config.go +45 -1
@@ -18,7 +18,10 @@ import (
1818
1919 const pollInterval = 2 * time.Second
2020
21-var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
21+var (
22+ processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`)
23+ percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`)
24+)
2225
2326 // Parse reads vev's flat action = value config format. Duplicate action keys
2427 // are accepted with a warning; the last value wins while first-seen action order
@@ -27,6 +30,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
2730 cfg := domain.Defaults()
2831 var warnings []domain.Warning
2932 seenBindingKeys := make(map[string]bool)
33+ seenFloatingKeys := make(map[string]bool)
3034
3135 scanner := bufio.NewScanner(r)
3236 lineNo := 0
@@ -86,6 +90,30 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
8690 interval = domain.MinBarInterval
8791 }
8892 cfg.Bar.Interval = interval
93+ case key == "floating.command":
94+ warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)
95+ command, ok := parseBarCommand(value)
96+ if !ok {
97+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)})
98+ continue
99+ }
100+ cfg.Floating.Command = command
101+ case key == "floating.width":
102+ warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)
103+ width, ok := parsePercentage(value)
104+ if !ok {
105+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)})
106+ continue
107+ }
108+ cfg.Floating.Width = width
109+ case key == "floating.height":
110+ warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo)
111+ height, ok := parsePercentage(value)
112+ if !ok {
113+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)})
114+ continue
115+ }
116+ cfg.Floating.Height = height
89117 case strings.HasPrefix(key, "code."):
90118 codeKey := strings.TrimPrefix(key, "code.")
91119 if codeKey == "" {
@@ -189,6 +217,14 @@ func fileStamp(path string) (stamp, error) {
189217 return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
190218 }
191219
220+func warnDuplicateFloatingKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning {
221+ if seen[key] {
222+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)})
223+ }
224+ seen[key] = true
225+ return warnings
226+}
227+
192228 func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
193229 for i := range entries {
194230 if entries[i].Key == key {
@@ -224,6 +260,14 @@ func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
224260 return out, warnings
225261 }
226262
263+func parsePercentage(value string) (int, bool) {
264+ if !percentagePattern.MatchString(value) {
265+ return 0, false
266+ }
267+ percentage, err := strconv.Atoi(strings.TrimSuffix(value, "%"))
268+ return percentage, err == nil && percentage >= 1 && percentage <= 100
269+}
270+
227271 func parseBarCommand(value string) (string, bool) {
228272 value = strings.TrimSpace(value)
229273 if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
M internal/adapters/config/config_test.go +94 -0
@@ -91,6 +91,97 @@ func TestParse(t *testing.T) {
9191 {Line: 3, Msg: "missing key"},
9292 },
9393 },
94+ {
95+ name: "floating settings custom values",
96+ input: "floating.command = \"btop --utf-force\"\nfloating.width = 75%\nfloating.height = 60%\n",
97+ want: domain.Config{
98+ Theme: domain.ThemeAuto,
99+ Floating: domain.FloatingConfig{
100+ Command: "btop --utf-force",
101+ Width: 75,
102+ Height: 60,
103+ },
104+ BindingEntries: []domain.ConfigEntry{},
105+ Codes: map[string]string{},
106+ },
107+ },
108+ {
109+ name: "floating command can be empty or quoted",
110+ input: "floating.command = echo ready\nfloating.command = \"\"\n",
111+ want: domain.Config{
112+ Theme: domain.ThemeAuto,
113+ Floating: domain.FloatingConfig{
114+ Width: 80,
115+ Height: 80,
116+ },
117+ BindingEntries: []domain.ConfigEntry{},
118+ Codes: map[string]string{},
119+ },
120+ wantWarnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"floating.command\""}},
121+ },
122+ {
123+ name: "floating percentage endpoints",
124+ input: "floating.width = 1%\nfloating.height = 100%\n",
125+ want: domain.Config{
126+ Theme: domain.ThemeAuto,
127+ Floating: domain.FloatingConfig{
128+ Width: 1,
129+ Height: 100,
130+ },
131+ BindingEntries: []domain.ConfigEntry{},
132+ Codes: map[string]string{},
133+ },
134+ },
135+ {
136+ name: "floating invalid percentages warn and preserve defaults",
137+ input: "floating.width = 80\nfloating.height = 0%\nfloating.width = 101%\nfloating.height = eighty%\n",
138+ want: domain.Config{
139+ Theme: domain.ThemeAuto,
140+ Floating: domain.FloatingConfig{
141+ Width: 80,
142+ Height: 80,
143+ },
144+ BindingEntries: []domain.ConfigEntry{},
145+ Codes: map[string]string{},
146+ },
147+ wantWarnings: []domain.Warning{
148+ {Line: 1, Msg: "invalid floating.width \"80\""},
149+ {Line: 2, Msg: "invalid floating.height \"0%\""},
150+ {Line: 3, Msg: "duplicate key \"floating.width\""},
151+ {Line: 3, Msg: "invalid floating.width \"101%\""},
152+ {Line: 4, Msg: "duplicate key \"floating.height\""},
153+ {Line: 4, Msg: "invalid floating.height \"eighty%\""},
154+ },
155+ },
156+ {
157+ name: "floating duplicate keys warn and last valid value wins",
158+ input: "floating.command = btop\nfloating.command = lazygit\nfloating.width = 70%\nfloating.width = 90%\nfloating.height = 60%\nfloating.height = 85%\n",
159+ want: domain.Config{
160+ Theme: domain.ThemeAuto,
161+ Floating: domain.FloatingConfig{
162+ Command: "lazygit",
163+ Width: 90,
164+ Height: 85,
165+ },
166+ BindingEntries: []domain.ConfigEntry{},
167+ Codes: map[string]string{},
168+ },
169+ wantWarnings: []domain.Warning{
170+ {Line: 2, Msg: "duplicate key \"floating.command\""},
171+ {Line: 4, Msg: "duplicate key \"floating.width\""},
172+ {Line: 6, Msg: "duplicate key \"floating.height\""},
173+ },
174+ },
175+ {
176+ name: "floating malformed quoted command warns and keeps default",
177+ input: "floating.command = \"bad\\q\"\n",
178+ want: domain.Config{
179+ Theme: domain.ThemeAuto,
180+ BindingEntries: []domain.ConfigEntry{},
181+ Codes: map[string]string{},
182+ },
183+ wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid floating.command \"\\\"bad\\\\q\\\"\""}},
184+ },
94185 {
95186 name: "bar settings custom values",
96187 input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n",
@@ -244,6 +335,9 @@ func TestParse(t *testing.T) {
244335 if tt.want.Bar.Interval == 0 {
245336 tt.want.Bar = domain.Defaults().Bar
246337 }
338+ if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 {
339+ tt.want.Floating = domain.Defaults().Floating
340+ }
247341 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
248342 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
249343 }
M internal/domain/config.go +12 -0
@@ -47,6 +47,13 @@ type BarConfig struct {
4747 Interval time.Duration
4848 }
4949
50+// FloatingConfig contains launch and sizing settings for per-tab floating panes.
51+type FloatingConfig struct {
52+ Command string
53+ Width int
54+ Height int
55+}
56+
5057 // Config is the user-editable vev configuration after parsing. Unknown binding
5158 // keys are preserved here (in BindingEntries, in file order) so the usecase
5259 // layer can decide which actions it understands.
@@ -56,6 +63,7 @@ type Config struct {
5663 BindingEntries []ConfigEntry
5764 Codes map[string]string
5865 Snapshot SnapshotConfig
66+ Floating FloatingConfig
5967 }
6068
6169 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -79,5 +87,9 @@ func Defaults() Config {
7987 Snapshot: SnapshotConfig{
8088 RestoreProcesses: DefaultSnapshotRestoreProcesses(),
8189 },
90+ Floating: FloatingConfig{
91+ Width: 80,
92+ Height: 80,
93+ },
8294 }
8395 }
M internal/usecase/command/command.go +1 -0
@@ -11,6 +11,7 @@ type Context interface {
1111 SplitDown() error
1212 StackPane() error
1313 ToggleStack() error
14+ ToggleFloatingPane() error
1415 ClosePane() error
1516 FocusPaneLeft() error
1617 FocusPaneRight() error
M internal/usecase/command/context_mock_test.go +44 -0
@@ -1003,6 +1003,50 @@ func (_c *MockContext_StackPane_Call) RunAndReturn(run func() error) *MockContex
10031003 return _c
10041004 }
10051005
1006+// ToggleFloatingPane provides a mock function for the type MockContext
1007+func (_mock *MockContext) ToggleFloatingPane() error {
1008+ ret := _mock.Called()
1009+
1010+ if len(ret) == 0 {
1011+ panic("no return value specified for ToggleFloatingPane")
1012+ }
1013+
1014+ var r0 error
1015+ if returnFunc, ok := ret.Get(0).(func() error); ok {
1016+ r0 = returnFunc()
1017+ } else {
1018+ r0 = ret.Error(0)
1019+ }
1020+ return r0
1021+}
1022+
1023+// MockContext_ToggleFloatingPane_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ToggleFloatingPane'
1024+type MockContext_ToggleFloatingPane_Call struct {
1025+ *mock.Call
1026+}
1027+
1028+// ToggleFloatingPane is a helper method to define mock.On call
1029+func (_e *MockContext_Expecter) ToggleFloatingPane() *MockContext_ToggleFloatingPane_Call {
1030+ return &MockContext_ToggleFloatingPane_Call{Call: _e.mock.On("ToggleFloatingPane")}
1031+}
1032+
1033+func (_c *MockContext_ToggleFloatingPane_Call) Run(run func()) *MockContext_ToggleFloatingPane_Call {
1034+ _c.Call.Run(func(args mock.Arguments) {
1035+ run()
1036+ })
1037+ return _c
1038+}
1039+
1040+func (_c *MockContext_ToggleFloatingPane_Call) Return(err error) *MockContext_ToggleFloatingPane_Call {
1041+ _c.Call.Return(err)
1042+ return _c
1043+}
1044+
1045+func (_c *MockContext_ToggleFloatingPane_Call) RunAndReturn(run func() error) *MockContext_ToggleFloatingPane_Call {
1046+ _c.Call.Return(run)
1047+ return _c
1048+}
1049+
10061050 // ToggleStack provides a mock function for the type MockContext
10071051 func (_mock *MockContext) ToggleStack() error {
10081052 ret := _mock.Called()
M internal/usecase/command/registry.go +1 -0
@@ -14,6 +14,7 @@ func Registry() []Command {
1414 {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }},
1515 {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }},
1616 {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }},
17+ {Slug: "toggle-floating-pane", Code: "FLT", Name: "Toggle floating pane", Desc: "Toggle the floating pane", Run: func(ctx Context) error { return ctx.ToggleFloatingPane() }},
1718 {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }},
1819 {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }},
1920 {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }},
M internal/usecase/command/registry_test.go +12 -1
@@ -7,7 +7,7 @@ import (
77
88 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
99 commands := Registry()
10- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
1111
1212 if len(commands) != len(wantCodes) {
1313 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -37,6 +37,16 @@ func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
3737 }
3838 }
3939
40+func TestRegistryIncludesFloatingPaneToggle(t *testing.T) {
41+ cmd, ok := ByCode("FLT")
42+ if !ok {
43+ t.Fatal("ByCode(\"FLT\") ok = false, want true")
44+ }
45+ if cmd.Slug != "toggle-floating-pane" || cmd.Code != "FLT" || cmd.Name != "Toggle floating pane" {
46+ t.Fatalf("floating command = %#v, want toggle-floating-pane/FLT/Toggle floating pane", cmd)
47+ }
48+}
49+
4050 func TestByCodeIsCaseInsensitive(t *testing.T) {
4151 for _, code := range []string{"CNT", "cnt", "CnT"} {
4252 cmd, ok := ByCode(code)
@@ -67,6 +77,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
6777 {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }},
6878 {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }},
6979 {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }},
80+ {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }},
7081 {code: "CLP", expect: func(ctx *MockContext) { ctx.EXPECT().ClosePane().Return(nil).Once() }},
7182 {code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }},
7283 {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }},
M internal/usecase/daemon/attention.go +1 -0
@@ -48,6 +48,7 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) {
4848 }
4949 if idx, ok := oldestAttentionTab(sess); ok {
5050 if sess.switchTab(idx) {
51+ d.activateTab(sess, sess.activeTab())
5152 d.paint(sess, ac, true)
5253 }
5354 return
M internal/usecase/daemon/attention_test.go +5 -0
@@ -440,6 +440,10 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
440440
441441 func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
442442 d, sess, ac, sends, releases := newManualTabSession(t, 3)
443+ sess.mu.Lock()
444+ sess.client = ac
445+ sess.mu.Unlock()
446+ d.ptys = newBlockingOpenFactory(t, d)
443447 defer releases[0]()
444448 defer releases[1]()
445449 defer releases[2]()
@@ -454,6 +458,7 @@ func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
454458 d.handleInput(sess, ac, []byte("\x1ba"))
455459
456460 require.Equal(t, 2, activeTabIndex(sess))
461+ requireFloatingInitialized(t, sess.activeTab())
457462 _ = mustOutputData(t, sends)
458463 }
459464
M internal/usecase/daemon/client.go +5 -0
@@ -443,10 +443,12 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
443443
444444 if clientSize.Valid() && wsz != tabSize(clientSize) {
445445 d.resize(sess, ac, clientSize)
446+ d.activateTab(sess, sess.activeTab())
446447 return
447448 }
448449 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
449450 d.paint(sess, ac, true)
451+ d.activateTab(sess, tb)
450452 }
451453
452454 // runConnLoop is the per-connection input router: it pumps client messages
@@ -594,6 +596,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
594596 d.applyLayoutLocked(tb)
595597 tb.mu.Unlock()
596598 }
599+ // Only the shown tab's floating terminal tracks the client size. This is
600+ // outside tab.mu so its PTY resize cannot block tab state.
601+ d.resizeActiveFloating(sess.activeTab())
597602 markSnapshotDirty(sess)
598603 if ac != nil {
599604 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
M internal/usecase/daemon/clipboard.go +1 -1
@@ -91,7 +91,7 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
9191 return
9292 }
9393 tb.mu.Lock()
94- p := tb.focusedPane()
94+ p := tb.terminalTargetLocked()
9595 tb.mu.Unlock()
9696 if p == nil {
9797 return
M internal/usecase/daemon/clipboard_test.go +19 -0
@@ -13,11 +13,30 @@ import (
1313 "github.com/stretchr/testify/mock"
1414 "github.com/stretchr/testify/require"
1515
16+ "github.com/bnema/vev/internal/domain"
1617 "github.com/bnema/vev/internal/ports"
1718 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1819 scopy "github.com/bnema/vev/internal/usecase/copy"
1920 )
2021
22+func TestInjectClipboardPathTargetsVisibleFloatingPane(t *testing.T) {
23+ normalWrites := make(chan []byte, 1)
24+ floatingWrites := make(chan []byte, 1)
25+ normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
26+ floatingPTY, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
27+ defer releaseNormal()
28+ defer releaseFloating()
29+ d, sess, _, _ := newManualSessionWithPTYs(t, normal)
30+ floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5})
31+ floating.screen.Write([]byte("\x1b[?2004h"))
32+ installTestFloating(sess.activeTab(), floating, true)
33+
34+ d.injectClipboardPath(sess, "/tmp/image.png")
35+
36+ requirePTYWrite(t, floatingWrites, []byte("\x1b[200~/tmp/image.png\x1b[201~"))
37+ requireNoPTYWrite(t, normalWrites)
38+}
39+
2140 func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {
2241 writes := make(chan []byte, 1)
2342 p, releasePTY := newBlockingPTYWithWrites(t, writes)
M internal/usecase/daemon/config.go +25 -8
@@ -27,6 +27,8 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
2727 d.bindings.Store(bindings)
2828 d.codeOverrides.Store(&overrides)
2929 d.restoreProcessAllowlist.Store(&allowlist)
30+ floating := cfg.Floating
31+ d.floatingConfig.Store(&floating)
3032 d.themeMode.Store(uint32(cfg.Theme))
3133 if d.barScripts != nil {
3234 d.barScripts.mu.Lock()
@@ -176,6 +178,13 @@ func (d *Daemon) logConfigWarning(w domain.Warning) {
176178 d.log.Warn("config warning", "msg", w.Msg)
177179 }
178180
181+func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {
182+ if cfg := d.floatingConfig.Load(); cfg != nil {
183+ return *cfg
184+ }
185+ return domain.Defaults().Floating
186+}
187+
179188 func (d *Daemon) codeOverrideSnapshot() map[string]string {
180189 overrides := d.codeOverrides.Load()
181190 if overrides == nil {
@@ -223,13 +232,21 @@ func (d *Daemon) reapplyThemeAllSessions() {
223232 d.mu.Unlock()
224233
225234 for _, sess := range sessions {
226- sess.mu.Lock()
227- ac := sess.client
228- sess.mu.Unlock()
229- var clientTheme theme.Theme
230- if ac != nil {
231- clientTheme = ac.getClientTheme()
232- }
233- d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme), ac == nil)
235+ d.reapplyThemeSession(sess)
236+ }
237+}
238+
239+func (d *Daemon) reapplyThemeSession(sess *session) {
240+ sess.mu.Lock()
241+ ac := sess.client
242+ sess.mu.Unlock()
243+ if ac == nil {
244+ d.applyHostTheme(sess, nil, d.effectiveTheme(theme.Theme{}), true)
245+ return
234246 }
247+ ac.sendMu.Lock()
248+ defer ac.sendMu.Unlock()
249+ sess.themeMu.Lock()
250+ defer sess.themeMu.Unlock()
251+ d.applyHostThemeLocked(sess, ac, d.effectiveTheme(ac.getClientTheme()), false)
235252 }
M internal/usecase/daemon/config_test.go +18 -0
@@ -49,6 +49,24 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
4949 require.Equal(t, "NT", cmd.Code)
5050 }
5151
52+func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
53+ d := newTestDaemon(t, nil, stubClock{})
54+ require.Equal(t, domain.Defaults().Floating, d.currentFloatingConfig())
55+
56+ firstConfig := domain.Defaults()
57+ firstConfig.Floating = domain.FloatingConfig{Command: "btop", Width: 70, Height: 60}
58+ d.ApplyConfig(firstConfig)
59+ first := d.currentFloatingConfig()
60+
61+ secondConfig := domain.Defaults()
62+ secondConfig.Floating = domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}
63+ d.ApplyConfig(secondConfig)
64+ second := d.currentFloatingConfig()
65+
66+ require.Equal(t, domain.FloatingConfig{Command: "btop", Width: 70, Height: 60}, first)
67+ require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second)
68+}
69+
5270 func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {
5371 tests := []struct {
5472 name string
M internal/usecase/daemon/copymode.go +46 -54
@@ -31,7 +31,6 @@ import (
3131 "github.com/bnema/vev/internal/ports"
3232 scopy "github.com/bnema/vev/internal/usecase/copy"
3333 "github.com/bnema/vev/internal/usecase/keys"
34- "github.com/bnema/vev/internal/usecase/layout"
3534 "github.com/bnema/vev/internal/usecase/mouse"
3635 "github.com/bnema/vev/internal/usecase/ui"
3736 "github.com/bnema/vev/internal/usecase/visualsearch"
@@ -40,22 +39,24 @@ import (
4039
4140 var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: ui.AnchorBottom, BottomMargin: 1}
4241
43-func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
44- rt := ac.overlays
45- tb := sess.activeTab()
46- if tb == nil {
47- return
42+func copyTargetPane(rt *overlayRuntime) *pane {
43+ if rt == nil {
44+ return nil
4845 }
46+ rt.copyMu.Lock()
47+ defer rt.copyMu.Unlock()
48+ return rt.copyPane
49+}
4950
50- tb.mu.Lock()
51- p := tb.focusedPane()
52- tb.mu.Unlock()
51+func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
52+ rt := ac.overlays
53+ p := copyTargetPane(rt)
5354 if p == nil {
5455 return
5556 }
5657 p.mu.Lock()
5758 rt.copyMu.Lock()
58- if rt.copyMode == nil {
59+ if rt.copyMode == nil || rt.copyPane != p {
5960 rt.copyMu.Unlock()
6061 p.mu.Unlock()
6162 return
@@ -86,7 +87,8 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
8687 return
8788 }
8889 tb.mu.Lock()
89- p := tb.focusedPane()
90+ p := tb.terminalTargetLocked()
91+ floatingSource := tb.floating.state == floatingVisible && tb.floating.pane == p
9092 tb.mu.Unlock()
9193 if p == nil {
9294 return
@@ -96,12 +98,21 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
9698 p.mu.Unlock()
9799 rt.copyMu.Lock()
98100 rt.copyMode = scopy.NewMode(snap)
101+ rt.copyPane = p
99102 rt.copySearch = nil
100103 rt.copySearchPending = nil
101104 rt.copyPressRowValid = false
102105 rt.copyDragging = false
103106 rt.normalMousePressValid = false
104107 rt.copyMu.Unlock()
108+ if floatingSource {
109+ tb.mu.Lock()
110+ stillVisible := tb.floating.state == floatingVisible && tb.floating.pane == p
111+ tb.mu.Unlock()
112+ if !stillVisible {
113+ rt.clearCopyModeForPane(p)
114+ }
115+ }
105116 d.paint(sess, ac, true)
106117 }
107118
@@ -110,14 +121,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
110121 if ev.Button != mouse.Left {
111122 return
112123 }
113- tb := sess.activeTab()
114- if tb == nil {
115- return
116- }
117-
118- tb.mu.Lock()
119- p := tb.focusedPane()
120- tb.mu.Unlock()
124+ p := copyTargetPane(rt)
121125 if p == nil {
122126 return
123127 }
@@ -133,7 +137,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
133137 return
134138 }
135139 rt.copyMu.Lock()
136- if rt.copyMode == nil {
140+ if rt.copyMode == nil || rt.copyPane != p {
137141 rt.copyMu.Unlock()
138142 p.mu.Unlock()
139143 return
@@ -175,20 +179,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
175179 if sess == nil {
176180 return
177181 }
178- tb := sess.activeTab()
179- if tb == nil {
180- return
181- }
182-
183- tb.mu.Lock()
184- p := tb.focusedPane()
185- tb.mu.Unlock()
182+ p := copyTargetPane(rt)
186183 if p == nil {
187184 return
188185 }
189186 p.mu.Lock()
190187 rt.copyMu.Lock()
191- if rt.copyMode == nil {
188+ if rt.copyMode == nil || rt.copyPane != p {
192189 rt.copyPending = nil
193190 d.stopCopyPendingTimerLocked(ac)
194191 rt.copyMu.Unlock()
@@ -420,36 +417,31 @@ func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame
420417 return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
421418 }
422419
423-func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {
424- styles := newThemeStyles(bars.theme)
425- p := tb.focusedPane()
426- if p == nil {
427- return renderer.NewFrame(0, 0), nil
420+func composeCopyClientFrame(mode *scopy.Mode, p *pane, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
421+ if mode == nil || p == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
422+ return frame, nil
428423 }
424+ styles := newThemeStyles(bars.theme)
429425 p.mu.Lock()
430- paneFrame := p.screen.Frame
431- paneWidth, paneRows := paneFrame.Width, paneFrame.Height
432- snap := scopy.NewSnapshot(p.scrollback, paneFrame)
426+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
433427 p.mu.Unlock()
434428 copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
435- pl, ok := focusedPlacementLocked(tb)
436- if !ok {
437- pl = layout.Placement{Content: domain.Rect{Width: paneWidth, Height: paneRows}}
438- }
439- width, screenRows := paneWidth, paneRows
440- if len(tb.panes) > 1 && tb.size.Valid() {
441- width, screenRows = tb.size.Cols, tb.size.Rows
442- }
443- frame := renderer.NewFrame(width, screenRows+2)
444- drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, bars.topRight, styles)
445- base, _ := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme)
446- for y := range screenRows {
447- copy(frame.Row(y+1), base.Row(y))
429+ bodyRows := max(copyFrame.Height-1, 0)
430+ for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
431+ dstX := max(target.X, 0)
432+ srcX := max(-target.X, 0)
433+ width := min(target.Width-srcX, copyFrame.Width-srcX)
434+ width = min(width, frame.Width-dstX)
435+ if width > 0 && target.Y+y >= 0 {
436+ copy(frame.Row(target.Y + y)[dstX:dstX+width], copyFrame.Row(y)[srcX:srcX+width])
437+ }
448438 }
449- for y := 0; y < pl.Content.Height && y < copyFrame.Height-1; y++ {
450- copy(frame.Row(pl.Content.Y + 1 + y)[pl.Content.X:pl.Content.X+min(pl.Content.Width, copyFrame.Width)], copyFrame.Row(y)[:min(pl.Content.Width, copyFrame.Width)])
439+ statusY := frame.Height - 1
440+ statusRow := frame.Row(statusY)
441+ blank := renderer.BlankCell()
442+ for x := range statusRow {
443+ statusRow[x] = blank
451444 }
452- statusY := screenRows + 1
453- copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(width, copyFrame.Width)])
445+ copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])
454446 return frame, []renderer.Damage{renderer.FullRedraw()}
455447 }
M internal/usecase/daemon/copymode_test.go +188 -9
@@ -17,6 +17,7 @@ import (
1717 "github.com/bnema/vev/internal/ports"
1818 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1919 scopy "github.com/bnema/vev/internal/usecase/copy"
20+ "github.com/bnema/vev/internal/usecase/layout"
2021 "github.com/bnema/vev/pkg/renderer"
2122 "github.com/bnema/vev/pkg/vt"
2223 )
@@ -107,6 +108,9 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
107108 mode := scopy.NewMode(snap)
108109 bars := barState{status: sess.statusSegments()}
109110
111+ base := renderer.NewFrame(80, 25)
112+ target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}
113+
110114 var wg sync.WaitGroup
111115 wg.Add(2)
112116 go func() {
@@ -120,9 +124,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
120124 go func() {
121125 defer wg.Done()
122126 for range 200 {
123- tb.mu.Lock()
124- _, _ = composeCopyClientFrame(mode, tb, bars)
125- tb.mu.Unlock()
127+ _, _ = composeCopyClientFrame(mode, pane, target, base, bars)
126128 }
127129 }()
128130 wg.Wait()
@@ -139,12 +141,15 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
139141 snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)
140142 mode := scopy.NewMode(snap)
141143
142- frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()})
143-
144- require.Equal(t, 12, frame.Width)
145- require.Equal(t, 5, frame.Height)
146- require.Equal(t, " 1 ", rowText(frame.Row(0)))
147- require.Contains(t, rowText(frame.Row(4)), "[SCROLL]")
144+ bars := barState{status: sess.statusSegments()}
145+ base, _ := composeClientFrameWithState(bars, tb, true)
146+ frame, damage := composeCopyClientFrame(mode, tb.focusedPane(), domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
147+
148+ require.Equal(t, 80, frame.Width)
149+ require.Equal(t, 25, frame.Height)
150+ require.Equal(t, " 1", strings.TrimRight(rowText(frame.Row(0)), " "))
151+ require.Contains(t, rowText(frame.Row(1)), "live")
152+ require.Contains(t, rowText(frame.Row(24)), "[SCROLL]")
148153 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
149154 }
150155
@@ -636,3 +641,177 @@ func TestCursorTailHidesInCopyMode(t *testing.T) {
636641 data := mustOutputData(t, sends)
637642 require.Contains(t, string(data), "\x1b[?25l")
638643 }
644+
645+func installFloatingCopyFixture(t *testing.T, sess *session, size domain.Size) *pane {
646+ t.Helper()
647+ floatingPTY, releaseFloating := newBlockingPTY(t)
648+ t.Cleanup(releaseFloating)
649+ fp := newPane("floating", floatingPTY, size)
650+ installTestFloating(sess.activeTab(), fp, true)
651+ return fp
652+}
653+
654+func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
655+ cases := []struct {
656+ name string
657+ useFloating bool
658+ wantText string
659+ }{
660+ {name: "floating source", useFloating: true, wantText: "flt-old\nflt-live"},
661+ {name: "normal source", useFloating: false, wantText: "one-old\none-live"},
662+ }
663+ for _, tc := range cases {
664+ t.Run(tc.name, func(t *testing.T) {
665+ normalWrites := make(chan []byte, 1)
666+ normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
667+ defer releaseNormal()
668+ d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
669+ tb := sess.activeTab()
670+ second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5})
671+ tb.mu.Lock()
672+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
673+ tb.panes["pane-2"] = second
674+ tb.mu.Unlock()
675+ main := tb.focusedPane()
676+ var want *pane
677+ if tc.useFloating {
678+ want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
679+ want.scrollback.Append(testRow("flt-old"))
680+ want.screen.Write([]byte("flt-live"))
681+ } else {
682+ want = main
683+ want.scrollback.Append(testRow("one-old"))
684+ want.screen.Write([]byte("one-live"))
685+ }
686+
687+ d.enterCopyMode(sess, ac)
688+ awaitFrame(t, sends, ports.MsgOutput)
689+ ac.overlays.copyMu.Lock()
690+ captured := ac.overlays.copyPane
691+ ac.overlays.copyMu.Unlock()
692+ require.Same(t, want, captured)
693+
694+ tb.mu.Lock()
695+ focusPlacementLocked(tb, "pane-2")
696+ tb.mu.Unlock()
697+
698+ d.handleInput(sess, ac, []byte{'g', ' ', 'j', 'y'})
699+ out := awaitFrame(t, sends, ports.MsgOutput)
700+ msg, err := ports.UnmarshalOutput(out.Payload)
701+ require.NoError(t, err)
702+ wantOSC := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(tc.wantText)) + "\x07"
703+ require.Equal(t, wantOSC, string(msg.Data))
704+ require.Nil(t, ac.overlays.copyMode)
705+ select {
706+ case got := <-normalWrites:
707+ t.Fatalf("copy-mode input forwarded to normal PTY: %q", got)
708+ default:
709+ }
710+ })
711+ }
712+}
713+
714+func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
715+ normal, releaseNormal := newBlockingPTY(t)
716+ defer releaseNormal()
717+ d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
718+ fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
719+ for i := range 30 {
720+ fp.scrollback.Append(testRow(fmt.Sprintf("old-%02d", i)))
721+ }
722+ fp.screen.Write([]byte("live"))
723+ fp.mu.Lock()
724+ total := len(scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Rows)
725+ fp.mu.Unlock()
726+
727+ d.enterCopyMode(sess, ac)
728+ awaitFrame(t, sends, ports.MsgOutput)
729+ require.Equal(t, total-1, ac.overlays.copyMode.Cursor)
730+
731+ d.copyWheel(sess, ac, -3)
732+ awaitFrame(t, sends, ports.MsgOutput)
733+ require.Equal(t, total-4, ac.overlays.copyMode.Cursor)
734+
735+ d.copyWheel(sess, ac, 3)
736+ awaitFrame(t, sends, ports.MsgOutput)
737+ require.Nil(t, ac.overlays.copyMode, "wheel down reaching the captured bottom exits copy mode")
738+}
739+
740+func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
741+ normal, releaseNormal := newBlockingPTY(t)
742+ defer releaseNormal()
743+ d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
744+ fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
745+ for i := range 5 {
746+ fp.scrollback.Append(testRow(fmt.Sprintf("old-%d", i)))
747+ }
748+ fp.screen.Write([]byte("live"))
749+
750+ d.enterCopyMode(sess, ac)
751+ awaitFrame(t, sends, ports.MsgOutput)
752+ viewportTop := ac.overlays.copyMode.ViewportTop
753+ inner := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 23}, d.currentFloatingConfig()).Inner
754+
755+ // Copy body row k renders at wire row inner.Y+k+2 (top bar + content
756+ // offset); clicking a row must select exactly that row.
757+ press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+3)
758+ d.handleInput(sess, ac, []byte(press))
759+ awaitFrame(t, sends, ports.MsgOutput)
760+ require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor)
761+
762+ motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+4)
763+ d.handleInput(sess, ac, []byte(motion))
764+ awaitFrame(t, sends, ports.MsgOutput)
765+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
766+ require.True(t, ok)
767+ require.Equal(t, viewportTop+1, lo)
768+ require.Equal(t, viewportTop+2, hi)
769+}
770+
771+func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) {
772+ normal, releaseNormal := newBlockingPTY(t)
773+ defer releaseNormal()
774+ d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
775+ tb := sess.activeTab()
776+ fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3})
777+ fp.screen.Write([]byte("flt-live"))
778+
779+ d.enterCopyMode(sess, ac)
780+ awaitFrame(t, sends, ports.MsgOutput)
781+ tb.mu.Lock()
782+ generation := tb.floating.generation
783+ tb.mu.Unlock()
784+
785+ d.reapFloating(sess, tb, fp, generation)
786+
787+ ac.overlays.copyMu.Lock()
788+ require.Nil(t, ac.overlays.copyMode)
789+ require.Nil(t, ac.overlays.copyPane)
790+ ac.overlays.copyMu.Unlock()
791+ data := mustOutputData(t, sends)
792+ require.NotContains(t, string(data), "[SCROLL]")
793+ require.NotContains(t, string(data), "flt-live")
794+}
795+
796+func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) {
797+ pty, _ := newBlockingPTY(t)
798+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
799+ pane := sess.tabs[0].focusedPane()
800+ copy(pane.screen.Frame.Row(0), testRow("alpha"))
801+ copy(pane.screen.Frame.Row(1), testRow("bravo"))
802+
803+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
804+ mustOutputData(t, sends)
805+ require.NotNil(t, ac.overlays.copyMode)
806+ ac.overlays.copyMu.Lock()
807+ captured := ac.overlays.copyPane
808+ ac.overlays.copyMu.Unlock()
809+ require.Same(t, pane, captured, "drag entry must capture the copy source pane")
810+
811+ d.handleInput(sess, ac, []byte("y"))
812+ out := awaitFrame(t, sends, ports.MsgOutput)
813+ msg, err := ports.UnmarshalOutput(out.Payload)
814+ require.NoError(t, err)
815+ want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha\nbravo")) + "\x07"
816+ require.Equal(t, want, string(msg.Data))
817+}
M internal/usecase/daemon/daemon.go +3 -0
@@ -135,6 +135,7 @@ type Daemon struct {
135135 bindings atomic.Pointer[keys.Bindings]
136136 codeOverrides atomic.Pointer[map[string]string]
137137 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
138+ floatingConfig atomic.Pointer[domain.FloatingConfig]
138139 themeMode atomic.Uint32
139140 barScripts *barScriptState
140141 resumeParkGrace time.Duration
@@ -315,6 +316,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
315316 running: make(map[domain.SessionID]bool),
316317 },
317318 }
319+ defaultFloating := domain.Defaults().Floating
320+ d.floatingConfig.Store(&defaultFloating)
318321 for _, o := range opts {
319322 o(d)
320323 }
M internal/usecase/daemon/daemon_test.go +66 -3
@@ -105,10 +105,66 @@ func newBlockingPTYWithWrites(t *testing.T, writes chan<- []byte) (*portsmocks.M
105105 return p, unblock
106106 }
107107
108+// quietPTY is an independently closable PTY for background floating prewarms.
109+// It intentionally has no testify expectations: tests using the factories below
110+// are asserting their regular panes, not incidental asynchronous prewarms.
111+type quietPTY struct{ done chan struct{} }
112+
113+func newQuietPTY() *quietPTY { return &quietPTY{done: make(chan struct{})} }
114+func (p *quietPTY) Read([]byte) (int, error) { <-p.done; return 0, io.EOF }
115+func (*quietPTY) Write(b []byte) (int, error) { return len(b), nil }
116+func (p *quietPTY) Close() error {
117+ select {
118+ case <-p.done:
119+ default:
120+ close(p.done)
121+ }
122+ return nil
123+}
124+func (*quietPTY) Resize(domain.Size) error { return nil }
125+func (*quietPTY) Pid() int { return 0 }
126+func (*quietPTY) ForegroundPgid() (int, error) { return 0, nil }
127+
128+// blockingOpenFactory keeps activation tests deterministically in Warming
129+// without starting pane reader/scheduler goroutines.
130+type blockingOpenFactory struct {
131+ release chan struct{}
132+ once sync.Once
133+}
134+
135+func newBlockingOpenFactory(t *testing.T, d *Daemon) *blockingOpenFactory {
136+ t.Helper()
137+ f := &blockingOpenFactory{release: make(chan struct{})}
138+ t.Cleanup(func() {
139+ f.once.Do(func() { close(f.release) })
140+ d.sessWg.Wait()
141+ })
142+ return f
143+}
144+
145+func (f *blockingOpenFactory) Open(string, []string, []string, string, domain.Size) (ports.PTY, error) {
146+ <-f.release
147+ return nil, io.ErrClosedPipe
148+}
149+
150+// newFactory keeps the supplied fixture for normal panes. Floating prewarms
151+// have a distinct (smaller) geometry and receive independent quiet PTYs, so
152+// they cannot consume test output or add post-test mock calls.
108153 func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {
109154 t.Helper()
110155 f := portsmocks.NewMockPTYFactory(t)
111- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Maybe()
156+ var normal domain.Size
157+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
158+ func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
159+ if !normal.Valid() {
160+ normal = sz
161+ }
162+ if sz == normal {
163+ return p, nil
164+ }
165+ return newQuietPTY(), nil
166+ },
167+ ).Maybe()
112168 return f
113169 }
114170
@@ -116,13 +172,20 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory {
116172 t.Helper()
117173 f := portsmocks.NewMockPTYFactory(t)
118174 var mu sync.Mutex
175+ var normal domain.Size
119176 next := 0
120177 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
121- func(string, []string, []string, string, domain.Size) (ports.PTY, error) {
178+ func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
122179 mu.Lock()
123180 defer mu.Unlock()
181+ if !normal.Valid() {
182+ normal = sz
183+ }
184+ if sz != normal {
185+ return newQuietPTY(), nil
186+ }
124187 if next >= len(ptys) {
125- t.Fatalf("unexpected PTY open #%d", next+1)
188+ return newQuietPTY(), nil
126189 }
127190 p := ptys[next]
128191 next++
A internal/usecase/daemon/floating.go +410 -0
@@ -0,0 +1,410 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "fmt"
6+ "path/filepath"
7+ "strings"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+)
12+
13+// floatingState describes the lifecycle of a tab's independent floating pane.
14+// A floating slot is always guarded by its owning tab.mu.
15+type floatingState uint8
16+
17+const (
18+ floatingUninitialized floatingState = iota
19+ floatingWarming
20+ floatingHidden
21+ floatingVisible
22+)
23+
24+// floatingSlot holds the runtime which is deliberately outside tab.tree and
25+// tab.panes. generation identifies an asynchronous launch and its installed
26+// pane, preventing stale launch completions and exits from changing new state.
27+type floatingSlot struct {
28+ state floatingState
29+ pane *pane
30+ desiredVisible bool
31+ generation uint64
32+}
33+
34+// beginFloatingWarmLocked starts a single background launch. The caller must
35+// hold tb.mu and must only call it for an uninitialized slot.
36+func (tb *tab) beginFloatingWarmLocked(desiredVisible bool) uint64 {
37+ if tb == nil || tb.floating.state != floatingUninitialized {
38+ return 0
39+ }
40+ tb.floating.generation++
41+ tb.floating.state = floatingWarming
42+ tb.floating.desiredVisible = desiredVisible
43+ return tb.floating.generation
44+}
45+
46+// toggleFloatingLocked applies the user action. Its result says whether a new
47+// PTY launch is required and returns the generation associated with that launch
48+// or current slot.
49+func (tb *tab) toggleFloatingLocked() (bool, uint64) {
50+ if tb == nil {
51+ return false, 0
52+ }
53+ switch tb.floating.state {
54+ case floatingUninitialized:
55+ generation := tb.beginFloatingWarmLocked(true)
56+ return generation != 0, generation
57+ case floatingWarming:
58+ tb.floating.desiredVisible = !tb.floating.desiredVisible
59+ case floatingHidden:
60+ tb.floating.state = floatingVisible
61+ case floatingVisible:
62+ tb.floating.state = floatingHidden
63+ }
64+ return false, tb.floating.generation
65+}
66+
67+// installFloatingLocked installs a completed launch only when it is still the
68+// current warming generation. Stale completions must close their PTY elsewhere.
69+func (tb *tab) installFloatingLocked(p *pane, generation uint64) bool {
70+ if tb == nil || p == nil || tb.floating.state != floatingWarming || tb.floating.generation != generation {
71+ return false
72+ }
73+ tb.floating.pane = p
74+ if tb.floating.desiredVisible {
75+ tb.floating.state = floatingVisible
76+ } else {
77+ tb.floating.state = floatingHidden
78+ }
79+ return true
80+}
81+
82+// failFloatingLocked clears only the current warming launch. It deliberately
83+// leaves generation intact; the next launch advances it.
84+func (tb *tab) failFloatingLocked(generation uint64) bool {
85+ if tb == nil || tb.floating.state != floatingWarming || tb.floating.generation != generation {
86+ return false
87+ }
88+ tb.floating.state = floatingUninitialized
89+ tb.floating.pane = nil
90+ tb.floating.desiredVisible = false
91+ return true
92+}
93+
94+// clearFloatingLocked clears a matching installed runtime. Matching both pane
95+// identity and generation prevents an old reader from reaping a newer pane.
96+func (tb *tab) clearFloatingLocked(p *pane, generation uint64) bool {
97+ if tb == nil || p == nil || tb.floating.pane != p || tb.floating.generation != generation ||
98+ (tb.floating.state != floatingHidden && tb.floating.state != floatingVisible) {
99+ return false
100+ }
101+ tb.takeFloatingLocked()
102+ return true
103+}
104+
105+// takeFloatingLocked invalidates the slot and detaches its installed pane. The
106+// caller must hold tb.mu and must close the returned pane after unlocking.
107+func (tb *tab) takeFloatingLocked() *pane {
108+ if tb == nil {
109+ return nil
110+ }
111+ p := tb.floating.pane
112+ tb.floating.generation++
113+ tb.floating.state = floatingUninitialized
114+ tb.floating.pane = nil
115+ tb.floating.desiredVisible = false
116+ return p
117+}
118+
119+// terminalTargetLocked chooses the visible floating terminal ahead of the
120+// normal layout target. The caller must hold tb.mu.
121+func (tb *tab) terminalTargetLocked() *pane {
122+ if tb != nil && tb.floating.state == floatingVisible && tb.floating.pane != nil {
123+ return tb.floating.pane
124+ }
125+ return tb.focusedPane()
126+}
127+
128+// visibleFloatingSnapshotLocked captures the visible popup and its current
129+// geometry under the tab lock, so input cannot mix a pane from one state with
130+// coordinates from another. The caller must hold tb.mu.
131+func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane, floatingGeometry, bool) {
132+ if tb == nil || tb.floating.state != floatingVisible || tb.floating.pane == nil {
133+ return nil, floatingGeometry{}, false
134+ }
135+ p := tb.floating.pane
136+ desired := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg)
137+ p.mu.Lock()
138+ geometry := p.committedFloatingGeometryLocked(desired)
139+ p.mu.Unlock()
140+ if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
141+ return nil, floatingGeometry{}, false
142+ }
143+ return p, geometry, true
144+}
145+
146+// ensureFloatingWarm starts the background prewarm exactly once for a tab.
147+func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
148+ if d == nil || sess == nil || tb == nil || d.ptys == nil {
149+ return
150+ }
151+ d.startFloating(sess, tb, false)
152+}
153+
154+// activateTab performs the work associated with making a tab the destination.
155+// It is deliberately idempotent: every tab transition can use this single
156+// hook, while inactive restored tabs remain cold until actually selected.
157+func (d *Daemon) activateTab(sess *session, tb *tab) {
158+ if d == nil || sess == nil || tb == nil {
159+ return
160+ }
161+ // A headless session has no actual terminal destination. Deferring warmup
162+ // until firstPaint keeps restored tabs cold and avoids launching children
163+ // merely because a tab was manipulated during teardown.
164+ sess.mu.Lock()
165+ attached := sess.client != nil
166+ sess.mu.Unlock()
167+ if !attached {
168+ return
169+ }
170+ d.ensureFloatingWarm(sess, tb)
171+ d.resizeInstalledFloating(tb)
172+}
173+
174+// toggleFloating changes only this tab's slot. Opening an uninitialized slot
175+// launches asynchronously; installed slots are retained when hidden.
176+func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
177+ if d == nil || sess == nil {
178+ return fmt.Errorf("floating pane: session required")
179+ }
180+ tb := sess.activeTab()
181+ if tb == nil {
182+ return layout.ErrNotFound
183+ }
184+ cfg := d.currentFloatingConfig()
185+ tb.mu.Lock()
186+ start, generation := tb.toggleFloatingLocked()
187+ // desiredVisible is meaningful only while a launch is warming. Installed
188+ // slots derive their resize and paint work from the resulting slot state.
189+ visible := tb.floating.state == floatingVisible
190+ tb.mu.Unlock()
191+ if start {
192+ d.launchFloating(sess, tb, cfg, generation, true)
193+ if ac != nil {
194+ d.paint(sess, ac, true)
195+ }
196+ return nil
197+ }
198+ if visible {
199+ d.resizeActiveFloating(tb)
200+ }
201+ if ac != nil {
202+ d.paint(sess, ac, true)
203+ }
204+ return nil
205+}
206+
207+// startFloating starts a prewarm if its slot remains uninitialized.
208+func (d *Daemon) startFloating(sess *session, tb *tab, visible bool) {
209+ cfg := d.currentFloatingConfig()
210+ tb.mu.Lock()
211+ generation := tb.beginFloatingWarmLocked(visible)
212+ tb.mu.Unlock()
213+ if generation != 0 {
214+ d.launchFloating(sess, tb, cfg, generation, visible)
215+ }
216+}
217+
218+// floatingLaunchSpec is an immutable snapshot of one launch. It is built
219+// before the worker starts so config reloads cannot affect that launch.
220+type floatingLaunchSpec struct {
221+ sessionName string
222+ cwd string
223+ size domain.Size
224+ geometry floatingGeometry
225+ paneStableID string
226+ env []string
227+ command string
228+ args []string
229+ fallback string
230+ parentCtx context.Context
231+ userOpen bool
232+}
233+
234+// launchFloating snapshots launch inputs, then accounts for a worker through
235+// Open, pane initialization, publication, and reader/scheduler startup.
236+func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) {
237+ spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, userOpen)
238+ if err != nil {
239+ d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err)
240+ return
241+ }
242+ // Count the launch before its goroutine starts. It remains counted through
243+ // install, including the reader/scheduler Adds, so shutdown cannot return
244+ // before a late Open completion has either been rejected or fully joined.
245+ d.sessWg.Go(func() { d.openAndInstallFloating(sess, tb, spec, generation) })
246+}
247+
248+func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) {
249+ sess.mu.Lock()
250+ name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx
251+ sess.mu.Unlock()
252+ tb.mu.Lock()
253+ geometry := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg)
254+ size := rectSize(geometry.Inner)
255+ if !size.Valid() {
256+ geometry = floatingGeometry{Bounds: domain.Rect{Width: 1, Height: 1}, Inner: domain.Rect{Width: 1, Height: 1}}
257+ size = rectSize(geometry.Inner)
258+ }
259+ focused := tb.focusedPane()
260+ tabStableID, tabCtx := tb.stableID, tb.ctx
261+ tb.mu.Unlock()
262+ if focused != nil && d.procCwd != nil && focused.pty != nil {
263+ if live, err := d.procCwd(focused.pty.Pid()); err == nil && live != "" {
264+ cwd = live
265+ }
266+ }
267+ if d.dirOrHome != nil {
268+ cwd = d.dirOrHome(cwd)
269+ }
270+ paneStableID, err := newStableID("p")
271+ if err != nil {
272+ return floatingLaunchSpec{sessionName: name}, err
273+ }
274+ if tabCtx == nil {
275+ tabCtx = sessCtx
276+ }
277+ if tabCtx == nil {
278+ tabCtx = context.Background()
279+ }
280+ command, args := d.shell, append([]string(nil), d.shellArgs...)
281+ if cfg.Command != "" {
282+ args = []string{"-lc", cfg.Command}
283+ }
284+ return floatingLaunchSpec{
285+ sessionName: name,
286+ cwd: cwd,
287+ size: size,
288+ geometry: geometry,
289+ paneStableID: paneStableID,
290+ env: d.childEnv(name, tabStableID, paneStableID, term),
291+ command: command,
292+ args: args,
293+ fallback: floatingCommandFallback(cfg.Command, d.shell),
294+ parentCtx: tabCtx,
295+ userOpen: userOpen,
296+ }, nil
297+}
298+
299+// openAndInstallFloating runs entirely in the launch worker. No PTY operation
300+// occurs under tb.mu; the pane is fully initialized before publication.
301+func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) {
302+ pty, err := d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size)
303+ if err != nil {
304+ d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err)
305+ return
306+ }
307+ p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size)
308+ p.rect = spec.geometry.Inner
309+ p.popupGeometry = spec.geometry
310+ p.title.displayFallback = spec.fallback
311+ p.ctx, p.cancel = context.WithCancel(spec.parentCtx)
312+ // The reader may run as soon as install returns; make its exit policy
313+ // immutable before publishing the pane to the slot.
314+ p.onExit = func() { d.reapFloating(sess, tb, p, generation) }
315+ d.installFloating(sess, tb, p, generation)
316+}
317+
318+func (d *Daemon) failFloatingLaunch(tb *tab, generation uint64, userOpen bool, sessionName string, err error) {
319+ tb.mu.Lock()
320+ current := tb.failFloatingLocked(generation)
321+ tb.mu.Unlock()
322+ if current {
323+ kind := "background-prewarm"
324+ if userOpen {
325+ kind = "user-open"
326+ }
327+ d.log.Warn("floating pty spawn failed", "err", err, "session", sessionName, "kind", kind)
328+ }
329+}
330+
331+// installFloating installs only the current generation. A stale successful
332+// launch is cancelled and closed outside the tab lock.
333+func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uint64) {
334+ tb.mu.Lock()
335+ installed := tb.installFloatingLocked(p, generation)
336+ visible := installed && tb.floating.state == floatingVisible
337+ tb.mu.Unlock()
338+ if !installed {
339+ closeFloatingPane(p)
340+ return
341+ }
342+ d.reapplyThemeSession(sess)
343+ d.startPaneGoroutines(sess, tb, p)
344+ if visible {
345+ d.resizeActiveFloating(tb)
346+ sess.mu.Lock()
347+ ac := sess.client
348+ sess.mu.Unlock()
349+ if ac != nil {
350+ d.paint(sess, ac, true)
351+ }
352+ }
353+}
354+
355+// reapFloating ignores old readers and restores the background only when the
356+// exiting popup was visible.
357+func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64) {
358+ tb.mu.Lock()
359+ visible := tb.floating.state == floatingVisible && tb.floating.pane == p && tb.floating.generation == generation
360+ cleared := tb.clearFloatingLocked(p, generation)
361+ tb.mu.Unlock()
362+ if !cleared {
363+ return
364+ }
365+ closeFloatingPane(p)
366+ sess.mu.Lock()
367+ ac := sess.client
368+ sess.mu.Unlock()
369+ copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
370+ if ac != nil && (visible || copyCleared) {
371+ d.paint(sess, ac, true)
372+ }
373+}
374+
375+// teardownFloating invalidates first, then releases resources outside tb.mu.
376+func (d *Daemon) teardownFloating(tb *tab, ac *attachedClient) {
377+ if tb == nil {
378+ return
379+ }
380+ tb.mu.Lock()
381+ p := tb.takeFloatingLocked()
382+ tb.mu.Unlock()
383+ if ac != nil {
384+ ac.overlays.clearCopyModeForPane(p)
385+ }
386+ closeFloatingPane(p)
387+}
388+
389+// closeFloatingPane releases a floating runtime at most once. It is called
390+// only after a generation/identity check has detached the pane from its slot.
391+func closeFloatingPane(p *pane) {
392+ if p == nil {
393+ return
394+ }
395+ p.floatingCloseOnce.Do(func() {
396+ if p.cancel != nil {
397+ p.cancel()
398+ }
399+ if p.pty != nil {
400+ _ = p.pty.Close()
401+ }
402+ })
403+}
404+
405+func floatingCommandFallback(command, shell string) string {
406+ if fields := strings.Fields(command); len(fields) > 0 {
407+ return filepath.Base(fields[0])
408+ }
409+ return filepath.Base(shell)
410+}
A internal/usecase/daemon/floating_render.go +224 -0
@@ -0,0 +1,224 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ themeui "github.com/bnema/vev/internal/usecase/theme"
6+ "github.com/bnema/vev/internal/usecase/ui"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+// floatingGeometry describes a popup's outer frame and terminal content area.
11+// Bounds and Inner are both relative to the supplied content rectangle.
12+type floatingGeometry struct {
13+ Bounds domain.Rect
14+ Inner domain.Rect
15+}
16+
17+func (g floatingGeometry) valid() bool {
18+ return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0
19+}
20+
21+// committedFloatingGeometryLocked returns the geometry whose PTY resize was
22+// last committed. Newly constructed test panes fall back to the requested
23+// geometry until their first resize. The caller holds p.mu.
24+func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry {
25+ if p.popupGeometry.valid() {
26+ return p.popupGeometry
27+ }
28+ return fallback
29+}
30+
31+// floatingAxisGeometry is the canonical percentage and border calculation for
32+// one popup axis. Popups smaller than three cells spend no cells on borders.
33+type floatingAxisGeometry struct {
34+ BoundsSize int
35+ InnerSize int
36+ BorderOffset int
37+}
38+
39+func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry {
40+ percent = min(max(percent, 1), 100)
41+ bounds := min(max(available*percent/100, 1), available)
42+ axis := floatingAxisGeometry{BoundsSize: bounds, InnerSize: bounds}
43+ if bounds >= 3 {
44+ axis.InnerSize -= 2
45+ axis.BorderOffset = 1
46+ }
47+ return axis
48+}
49+
50+// calculateFloatingGeometry centers a percentage-sized popup in content.
51+// Launch sizing derives from Inner too, so rendering and PTY geometry share
52+// the same per-axis percentage and tiny-border rules.
53+func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) floatingGeometry {
54+ if content.Width <= 0 || content.Height <= 0 {
55+ return floatingGeometry{}
56+ }
57+ x := calculateFloatingAxisGeometry(content.Width, cfg.Width)
58+ y := calculateFloatingAxisGeometry(content.Height, cfg.Height)
59+ bounds := domain.Rect{
60+ X: content.X + (content.Width-x.BoundsSize)/2,
61+ Y: content.Y + (content.Height-y.BoundsSize)/2,
62+ Width: x.BoundsSize,
63+ Height: y.BoundsSize,
64+ }
65+ return floatingGeometry{
66+ Bounds: bounds,
67+ Inner: domain.Rect{
68+ X: bounds.X + x.BorderOffset,
69+ Y: bounds.Y + y.BorderOffset,
70+ Width: x.InnerSize,
71+ Height: y.InnerSize,
72+ },
73+ }
74+}
75+
76+// composeFloatingFrame applies the popup to a copy of the normal composed
77+// destination. In particular it never writes the VT screen frame: that frame
78+// is shared with the PTY reader and is the source for future renders.
79+func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, cfg domain.FloatingConfig, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage) {
80+ var frame renderer.Frame
81+ if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height {
82+ frame = cache.floatingFrame
83+ for y := 0; y < base.Height; y++ {
84+ copy(frame.Row(y), base.Row(y))
85+ }
86+ } else {
87+ frame = base.Clone()
88+ if cache != nil {
89+ cache.floatingFrame = frame
90+ }
91+ }
92+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
93+ desiredGeometry := calculateFloatingGeometry(content, cfg)
94+
95+ // The PTY reader mutates both the frame and its damage under p.mu. Keep
96+ // every read of them, including the blit, in the same critical section.
97+ // In particular, Frame is not safe to retain as an alias after unlocking.
98+ p.mu.Lock()
99+ geometry := p.committedFloatingGeometryLocked(desiredGeometry)
100+ title := p.displayTitleLocked()
101+ titleGeneration := p.title.generation
102+ screenDamage := p.screen.Damage()
103+ blitPaneFrame(frame, geometry.Inner, p.screen.Frame, false, theme)
104+ damage := append([]renderer.Damage(nil), baseDamage...)
105+ for _, d := range screenDamage {
106+ damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
107+ }
108+ p.screen.ClearDamage()
109+ p.mu.Unlock()
110+
111+ // The committed geometry, rather than the requested config geometry, is
112+ // what was actually rendered. Track it so movement (including a same-size
113+ // resize) cannot reuse a cached popup at its former position.
114+ popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry
115+ titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration
116+ drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)
117+ if cache != nil {
118+ cache.floating = p
119+ cache.floatingGeneration = generation
120+ cache.floatingGeometry = geometry
121+ cache.floatingTitleGeneration = titleGeneration
122+ }
123+ if full || popupChanged {
124+ return frame, []renderer.Damage{renderer.FullRedraw()}
125+ }
126+ if titleChanged && geometry.Bounds.Height >= 3 {
127+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
128+ }
129+ return frame, damage
130+}
131+
132+func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {
133+ if bounds.Width <= 0 || bounds.Height <= 0 {
134+ return
135+ }
136+ // Each axis omits its borders independently for tiny popups, matching
137+ // floatingInnerSize and leaving every cell available to the terminal.
138+ if bounds.Height >= 3 {
139+ for x := bounds.X; x < bounds.X+bounds.Width; x++ {
140+ frame.Set(x, bounds.Y, renderer.Cell{Rune: '─', Style: style})
141+ frame.Set(x, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '─', Style: style})
142+ }
143+ }
144+ if bounds.Width >= 3 {
145+ for y := bounds.Y; y < bounds.Y+bounds.Height; y++ {
146+ frame.Set(bounds.X, y, renderer.Cell{Rune: '│', Style: style})
147+ frame.Set(bounds.X+bounds.Width-1, y, renderer.Cell{Rune: '│', Style: style})
148+ }
149+ }
150+ if bounds.Width >= 3 && bounds.Height >= 3 {
151+ frame.Set(bounds.X, bounds.Y, renderer.Cell{Rune: '┌', Style: style})
152+ frame.Set(bounds.X+bounds.Width-1, bounds.Y, renderer.Cell{Rune: '┐', Style: style})
153+ frame.Set(bounds.X, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '└', Style: style})
154+ frame.Set(bounds.X+bounds.Width-1, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '┘', Style: style})
155+ ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)
156+ }
157+}
158+
159+// resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize
160+// is intentionally outside pane.mu; failed resizes do not publish any state.
161+func (d *Daemon) resizeFloatingPane(p *pane, geometry floatingGeometry) bool {
162+ inner := geometry.Inner
163+ if p == nil || !geometry.valid() {
164+ return false
165+ }
166+ p.resizeMu.Lock()
167+ defer p.resizeMu.Unlock()
168+
169+ p.mu.Lock()
170+ old := p.rect
171+ pty := p.pty
172+ p.mu.Unlock()
173+ if old.Width == inner.Width && old.Height == inner.Height {
174+ // Position and outer bounds can change while the terminal dimensions do
175+ // not. Commit that geometry without needlessly resetting the PTY/screen.
176+ p.mu.Lock()
177+ p.rect = inner
178+ p.popupGeometry = geometry
179+ p.mu.Unlock()
180+ return true
181+ }
182+ if pty != nil {
183+ if err := pty.Resize(rectSize(inner)); err != nil {
184+ d.log.Warn("floating pty resize failed", "err", err)
185+ return false
186+ }
187+ }
188+ p.mu.Lock()
189+ p.screen.Resize(inner.Width, inner.Height)
190+ p.rect = inner
191+ p.popupGeometry = geometry
192+ p.mu.Unlock()
193+ return true
194+}
195+
196+// resizeInstalledFloating updates an installed floating pane, including a
197+// retained hidden pane when its tab becomes active.
198+func (d *Daemon) resizeInstalledFloating(tb *tab) bool {
199+ if d == nil || tb == nil {
200+ return false
201+ }
202+ tb.mu.Lock()
203+ p := tb.floating.pane
204+ content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
205+ tb.mu.Unlock()
206+ if p == nil {
207+ return false
208+ }
209+ return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig()))
210+}
211+
212+// resizeActiveFloating updates a visible floating pane during a client resize.
213+func (d *Daemon) resizeActiveFloating(tb *tab) bool {
214+ if d == nil || tb == nil {
215+ return false
216+ }
217+ tb.mu.Lock()
218+ visible := tb.floating.state == floatingVisible
219+ tb.mu.Unlock()
220+ if !visible {
221+ return false
222+ }
223+ return d.resizeInstalledFloating(tb)
224+}
A internal/usecase/daemon/floating_render_test.go +444 -0
@@ -0,0 +1,444 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "strings"
7+ "sync"
8+ "testing"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ themeui "github.com/bnema/vev/internal/usecase/theme"
14+ "github.com/bnema/vev/pkg/renderer"
15+)
16+
17+func TestCalculateFloatingGeometry(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ content domain.Rect
21+ cfg domain.FloatingConfig
22+ want floatingGeometry
23+ }{
24+ {"eighty percent centered", domain.Rect{X: 3, Y: 1, Width: 101, Height: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Bounds: domain.Rect{X: 13, Y: 6, Width: 80, Height: 40}, Inner: domain.Rect{X: 14, Y: 7, Width: 78, Height: 38}}},
25+ {"one percent clamps to one", domain.Rect{Y: 1, Width: 100, Height: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Bounds: domain.Rect{X: 49, Y: 10, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 10, Width: 1, Height: 1}}},
26+ {"full size", domain.Rect{Y: 1, Width: 100, Height: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Y: 1, Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 2, Width: 98, Height: 18}}},
27+ {"tiny axes omit borders", domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}},
28+ {"percent clamps", domain.Rect{Width: 10, Height: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}},
29+ }
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ require.Equal(t, tt.want, calculateFloatingGeometry(tt.content, tt.cfg))
33+ })
34+ }
35+}
36+
37+func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
38+ tests := []struct {
39+ name string
40+ available, percent int
41+ want floatingAxisGeometry
42+ }{
43+ {name: "one percent", available: 100, percent: 1, want: floatingAxisGeometry{BoundsSize: 1, InnerSize: 1}},
44+ {name: "full size reserves borders", available: 100, percent: 100, want: floatingAxisGeometry{BoundsSize: 100, InnerSize: 98, BorderOffset: 1}},
45+ {name: "two cells omits borders", available: 2, percent: 100, want: floatingAxisGeometry{BoundsSize: 2, InnerSize: 2}},
46+ {name: "three cells leaves one inner cell", available: 3, percent: 100, want: floatingAxisGeometry{BoundsSize: 3, InnerSize: 1, BorderOffset: 1}},
47+ {name: "percentage clamps", available: 10, percent: 101, want: floatingAxisGeometry{BoundsSize: 10, InnerSize: 8, BorderOffset: 1}},
48+ }
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ require.Equal(t, tt.want, calculateFloatingAxisGeometry(tt.available, tt.percent))
52+ })
53+ }
54+}
55+
56+func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {
57+ p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3})
58+ p.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'})
59+ p.title.processName, p.title.terminalTitle, p.title.generation = "nvim", "project/main.go", 1
60+ base := renderer.NewFrame(40, 12)
61+ base.Set(2, 2, renderer.Cell{Rune: 'B'})
62+ content := domain.Rect{Y: 1, Width: 40, Height: 10}
63+ cache := &composedFrameCache{}
64+ frame, _ := composeFloatingFrame(base, nil, p, 1, content, domain.FloatingConfig{Width: 80, Height: 80}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
65+ require.Equal(t, 'B', base.At(2, 2).Rune, "backdrop must only touch the composed destination")
66+ geometry := calculateFloatingGeometry(content, domain.FloatingConfig{Width: 80, Height: 80})
67+ require.Equal(t, '┌', frame.At(geometry.Bounds.X, geometry.Bounds.Y).Rune)
68+ require.Equal(t, 'F', frame.At(geometry.Inner.X, geometry.Inner.Y).Rune)
69+ var gotTitle strings.Builder
70+ for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ {
71+ gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune)
72+ }
73+ require.Equal(t, "nvim: project/main.go", strings.TrimRight(gotTitle.String(), "─"))
74+
75+ p.mu.Lock()
76+ p.title.generation++
77+ p.mu.Unlock()
78+ _, damage := composeFloatingFrame(base, nil, p, 1, content, domain.FloatingConfig{Width: 80, Height: 80}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
79+ require.Len(t, damage, 1)
80+ require.Equal(t, geometry.Bounds.Y, damage[0].Y)
81+ require.Equal(t, 1, damage[0].Height)
82+}
83+
84+func TestComposeFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {
85+ initial := floatingGeometry{
86+ Bounds: domain.Rect{X: 4, Y: 2, Width: 8, Height: 5},
87+ Inner: domain.Rect{X: 5, Y: 3, Width: 6, Height: 3},
88+ }
89+ tests := []struct {
90+ name string
91+ next floatingGeometry
92+ }{
93+ {
94+ name: "position change",
95+ next: floatingGeometry{Bounds: domain.Rect{X: 12, Y: 4, Width: 8, Height: 5}, Inner: domain.Rect{X: 13, Y: 5, Width: 6, Height: 3}},
96+ },
97+ {
98+ name: "bounds change with same inner size",
99+ next: floatingGeometry{Bounds: domain.Rect{X: 10, Y: 2, Width: 10, Height: 7}, Inner: domain.Rect{X: 12, Y: 4, Width: 6, Height: 3}},
100+ },
101+ }
102+ for _, tt := range tests {
103+ t.Run(tt.name, func(t *testing.T) {
104+ p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3})
105+ p.rect, p.popupGeometry = initial.Inner, initial
106+ cache := &composedFrameCache{}
107+ base := renderer.NewFrame(30, 12)
108+ content := domain.Rect{Y: 1, Width: 30, Height: 10}
109+ _, damage := composeFloatingFrame(base, nil, p, 1, content, domain.FloatingConfig{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
110+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
111+
112+ p.mu.Lock()
113+ p.rect, p.popupGeometry = tt.next.Inner, tt.next
114+ p.mu.Unlock()
115+ _, damage = composeFloatingFrame(base, nil, p, 1, content, domain.FloatingConfig{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
116+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
117+ require.Equal(t, tt.next, cache.floatingGeometry)
118+ })
119+ }
120+}
121+
122+func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T) {
123+ initial := floatingGeometry{
124+ Bounds: domain.Rect{X: 1, Y: 1, Width: 8, Height: 5},
125+ Inner: domain.Rect{X: 2, Y: 2, Width: 6, Height: 3},
126+ }
127+ tests := []struct {
128+ name string
129+ next floatingGeometry
130+ }{
131+ {
132+ name: "position change",
133+ next: floatingGeometry{Bounds: domain.Rect{X: 9, Y: 4, Width: 8, Height: 5}, Inner: domain.Rect{X: 10, Y: 5, Width: 6, Height: 3}},
134+ },
135+ {
136+ name: "bounds change",
137+ next: floatingGeometry{Bounds: domain.Rect{X: 3, Y: 1, Width: 10, Height: 7}, Inner: domain.Rect{X: 5, Y: 3, Width: 6, Height: 3}},
138+ },
139+ }
140+ for _, tt := range tests {
141+ t.Run(tt.name, func(t *testing.T) {
142+ pty := &resizePTY{}
143+ p := newPane("floating", pty, rectSize(initial.Inner))
144+ p.rect, p.popupGeometry = initial.Inner, initial
145+ d := newTestDaemon(t, nil, stubClock{})
146+
147+ require.True(t, d.resizeFloatingPane(p, tt.next))
148+ require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY")
149+ require.Equal(t, tt.next.Inner, p.rect)
150+ require.Equal(t, tt.next, p.popupGeometry)
151+ require.Equal(t, 6, p.screen.Frame.Width)
152+ require.Equal(t, 3, p.screen.Frame.Height)
153+ })
154+ }
155+}
156+
157+func TestComposeFloatingFrameRendersPaneOwnedCommandFallback(t *testing.T) {
158+ d := newTestDaemon(t, nil, stubClock{})
159+ d.shell = "/usr/bin/fish"
160+ p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3})
161+ p.title.displayFallback = floatingCommandFallback("btop --utf", d.shell)
162+ require.Equal(t, "btop", d.refreshPaneDisplayTitle(nil, p, true))
163+
164+ base := renderer.NewFrame(40, 12)
165+ content := domain.Rect{Y: 1, Width: 40, Height: 10}
166+ cfg := domain.FloatingConfig{Width: 80, Height: 80}
167+ frame, _ := composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, false)
168+ geometry := calculateFloatingGeometry(content, cfg)
169+ var gotTitle strings.Builder
170+ for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ {
171+ gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune)
172+ }
173+ require.Equal(t, "btop", strings.TrimRight(gotTitle.String(), "─"))
174+}
175+
176+func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
177+ p := newPane("floating", nil, domain.Size{Cols: 80, Rows: 24})
178+ base := renderer.NewFrame(80, 24)
179+ content := domain.Rect{Width: 80, Height: 24}
180+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
181+
182+ start := make(chan struct{})
183+ var wg sync.WaitGroup
184+ wg.Add(2)
185+ go func() {
186+ defer wg.Done()
187+ <-start
188+ for range 500 {
189+ // This is the same pane lock used by ptyReader around Screen.Write.
190+ p.mu.Lock()
191+ p.screen.Write([]byte("\x1b[Hreader"))
192+ p.mu.Unlock()
193+ }
194+ }()
195+ go func() {
196+ defer wg.Done()
197+ <-start
198+ cache := &composedFrameCache{}
199+ for range 500 {
200+ composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
201+ }
202+ }()
203+ close(start)
204+ wg.Wait()
205+}
206+
207+func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
208+ frame := renderer.NewFrame(2, 1)
209+ drawFloatingBorder(frame, domain.Rect{Width: 2, Height: 1}, "title", renderer.Style{})
210+ for _, cell := range frame.Cells {
211+ require.Equal(t, renderer.BlankCell().Rune, cell.Rune)
212+ }
213+}
214+
215+func TestRenderHiddenFloatingSkipsPreviewAndActiveClientAndClearsDamage(t *testing.T) {
216+ normalPTY, releaseNormal := newBlockingPTY(t)
217+ defer releaseNormal()
218+ d, sess, _, activeSends := newManualSessionWithPTYs(t, normalPTY)
219+ previewTransport, previewSends := newCapturingTransport(t)
220+ preview := &attachedClient{tr: previewTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
221+ preview.initOverlays()
222+ preview.setSession(sess)
223+
224+ tb := sess.activeTab()
225+ floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 8})
226+ floating.screen.Write([]byte("hidden output"))
227+ tb.mu.Lock()
228+ generation := tb.beginFloatingWarmLocked(false)
229+ require.True(t, tb.installFloatingLocked(floating, generation))
230+ tb.previewClient = preview
231+ tb.mu.Unlock()
232+
233+ d.render(sess, tb, floating)
234+
235+ floating.mu.Lock()
236+ require.Empty(t, floating.screen.Damage())
237+ floating.mu.Unlock()
238+ select {
239+ case frame := <-previewSends:
240+ t.Fatalf("hidden floating output painted picker preview: %#v", frame)
241+ default:
242+ }
243+ select {
244+ case frame := <-activeSends:
245+ t.Fatalf("hidden floating output painted active client: %#v", frame)
246+ default:
247+ }
248+}
249+
250+func BenchmarkComposeFloatingFrameCached(b *testing.B) {
251+ p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
252+ p.title.displayFallback = "float"
253+ base := renderer.NewFrame(80, 24)
254+ content := domain.Rect{Y: 1, Width: 80, Height: 22}
255+ cfg := domain.FloatingConfig{Width: 80, Height: 80}
256+ cache := &composedFrameCache{}
257+ composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
258+ p.screen.ClearDamage()
259+
260+ b.ReportAllocs()
261+ b.ResetTimer()
262+ for b.Loop() {
263+ composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
264+ }
265+}
266+
267+func TestComposeFloatingFrameCachedDoesNotAllocate(t *testing.T) {
268+ p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
269+ p.title.displayFallback = "float"
270+ base := renderer.NewFrame(80, 24)
271+ content := domain.Rect{Y: 1, Width: 80, Height: 22}
272+ cfg := domain.FloatingConfig{Width: 80, Height: 80}
273+ cache := &composedFrameCache{}
274+ composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
275+ p.screen.ClearDamage()
276+
277+ allocs := testing.AllocsPerRun(100, func() {
278+ composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
279+ })
280+ require.Zero(t, allocs, "cached floating composition must not allocate without damage or title changes")
281+}
282+
283+func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
284+ d := newTestDaemon(t, nil, stubClock{})
285+ d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}})
286+ tb := newTab(nil, domain.Size{Cols: 80, Rows: 24})
287+ sess := &session{tabs: []*tab{tb}}
288+
289+ initialGeometry := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 24}, d.currentFloatingConfig())
290+ currentGeometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 40}, d.currentFloatingConfig())
291+ initial := initialGeometry.Inner
292+ current := currentGeometry.Inner
293+ pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}}
294+ floating := newPane("floating", pty, rectSize(initial))
295+ floating.rect = initial
296+ floating.popupGeometry = initialGeometry
297+
298+ // Prewarming installs a hidden pane at the initial geometry.
299+ tb.mu.Lock()
300+ generation := tb.beginFloatingWarmLocked(false)
301+ require.True(t, tb.installFloatingLocked(floating, generation))
302+ require.Equal(t, floatingHidden, tb.floating.state)
303+ require.False(t, tb.floating.desiredVisible)
304+ tb.mu.Unlock()
305+ // A client resize changes tab geometry but must leave the hidden PTY alone.
306+ d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42})
307+ require.Empty(t, pty.sizes())
308+
309+ // Showing attempts the current size before paint. A failed resize leaves the
310+ // old screen and rect untouched, so a later hide/show can retry.
311+ require.NoError(t, d.toggleFloating(sess, nil))
312+ require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())
313+ require.Equal(t, initial, floating.rect)
314+ require.Equal(t, initial.Width, floating.screen.Frame.Width)
315+ require.Equal(t, initial.Height, floating.screen.Frame.Height)
316+ require.Equal(t, initialGeometry, floating.popupGeometry)
317+
318+ require.NoError(t, d.toggleFloating(sess, nil)) // hide
319+ require.NoError(t, d.toggleFloating(sess, nil)) // retry show
320+ require.Equal(t, []domain.Size{rectSize(current), rectSize(current)}, pty.sizes())
321+ require.Equal(t, current, floating.rect)
322+ require.Equal(t, current.Width, floating.screen.Frame.Width)
323+ require.Equal(t, current.Height, floating.screen.Frame.Height)
324+ require.Equal(t, currentGeometry, floating.popupGeometry)
325+}
326+
327+func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) {
328+ cfg := domain.FloatingConfig{Width: 50, Height: 50}
329+ oldContent := domain.Rect{Y: 1, Width: 80, Height: 24}
330+ newContent := domain.Rect{Y: 1, Width: 100, Height: 40}
331+ oldGeometry := calculateFloatingGeometry(oldContent, cfg)
332+ newGeometry := calculateFloatingGeometry(newContent, cfg)
333+ pty := &resizePTY{err: errors.New("resize failed")}
334+ p := newPane("floating", pty, rectSize(oldGeometry.Inner))
335+ p.rect = oldGeometry.Inner
336+ p.popupGeometry = oldGeometry
337+ tb := newTab(nil, domain.Size{Cols: newContent.Width, Rows: newContent.Height})
338+ installTestFloating(tb, p, true)
339+ d := newTestDaemon(t, nil, stubClock{})
340+ d.ApplyConfig(domain.Config{Floating: cfg})
341+
342+ require.False(t, d.resizeFloatingPane(p, newGeometry))
343+ tb.mu.Lock()
344+ _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
345+ copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, cfg)
346+ tb.mu.Unlock()
347+ require.True(t, visible)
348+ require.Equal(t, oldGeometry, inputGeometry)
349+ require.Equal(t, oldGeometry.Inner, copyRect)
350+
351+ base := renderer.NewFrame(newContent.Width, newContent.Height+2)
352+ frame, _ := composeFloatingFrame(base, nil, p, 1, newContent, cfg, tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, true)
353+ require.Equal(t, '┌', frame.At(oldGeometry.Bounds.X, oldGeometry.Bounds.Y).Rune)
354+ if newGeometry.Bounds.X != oldGeometry.Bounds.X || newGeometry.Bounds.Y != oldGeometry.Bounds.Y {
355+ require.NotEqual(t, '┌', frame.At(newGeometry.Bounds.X, newGeometry.Bounds.Y).Rune)
356+ }
357+}
358+
359+func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) {
360+ t.Run("failure preserves state", func(t *testing.T) {
361+ pty := &resizePTY{err: errors.New("nope")}
362+ p := newPane("floating", pty, domain.Size{Cols: 5, Rows: 4})
363+ p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}
364+ d := newTestDaemon(t, nil, stubClock{})
365+ requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}}
366+ require.False(t, d.resizeFloatingPane(p, requested))
367+ require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect)
368+ require.Equal(t, floatingGeometry{}, p.popupGeometry)
369+ require.Equal(t, 5, p.screen.Frame.Width)
370+ require.Equal(t, 4, p.screen.Frame.Height)
371+ })
372+
373+ t.Run("competing resizes serialize", func(t *testing.T) {
374+ pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})}
375+ p := newPane("floating", pty, domain.Size{Cols: 2, Rows: 2})
376+ d := newTestDaemon(t, nil, stubClock{})
377+ first := floatingGeometry{Bounds: domain.Rect{Width: 6, Height: 5}, Inner: domain.Rect{X: 1, Y: 1, Width: 4, Height: 3}}
378+ second := floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 8}, Inner: domain.Rect{X: 1, Y: 1, Width: 8, Height: 6}}
379+ done1 := make(chan bool, 1)
380+ done2 := make(chan bool, 1)
381+ secondStarted := make(chan struct{})
382+ go func() { done1 <- d.resizeFloatingPane(p, first) }()
383+ <-pty.entered
384+ go func() {
385+ close(secondStarted)
386+ done2 <- d.resizeFloatingPane(p, second)
387+ }()
388+ <-secondStarted
389+ close(pty.release)
390+ require.True(t, <-done1)
391+ require.True(t, <-done2)
392+ require.Equal(t, second.Inner, p.rect)
393+ require.Equal(t, second, p.popupGeometry)
394+ require.Equal(t, 1, pty.maxConcurrentCalls())
395+ require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes())
396+ })
397+}
398+
399+type resizePTY struct {
400+ mu sync.Mutex
401+ resizes []domain.Size
402+ err error
403+ errs []error
404+ entered chan struct{}
405+ release chan struct{}
406+ activeCalls int
407+ maxConcurrent int
408+}
409+
410+func (p *resizePTY) Resize(sz domain.Size) error {
411+ p.mu.Lock()
412+ p.resizes = append(p.resizes, sz)
413+ p.activeCalls++
414+ p.maxConcurrent = max(p.maxConcurrent, p.activeCalls)
415+ n := len(p.resizes)
416+ err := p.err
417+ if n <= len(p.errs) {
418+ err = p.errs[n-1]
419+ }
420+ p.mu.Unlock()
421+ if n == 1 && p.entered != nil {
422+ close(p.entered)
423+ <-p.release
424+ }
425+ p.mu.Lock()
426+ p.activeCalls--
427+ p.mu.Unlock()
428+ return err
429+}
430+func (p *resizePTY) maxConcurrentCalls() int {
431+ p.mu.Lock()
432+ defer p.mu.Unlock()
433+ return p.maxConcurrent
434+}
435+func (p *resizePTY) sizes() []domain.Size {
436+ p.mu.Lock()
437+ defer p.mu.Unlock()
438+ return append([]domain.Size(nil), p.resizes...)
439+}
440+func (*resizePTY) Read([]byte) (int, error) { return 0, io.EOF }
441+func (*resizePTY) Write(b []byte) (int, error) { return len(b), nil }
442+func (*resizePTY) Close() error { return nil }
443+func (*resizePTY) Pid() int { return 0 }
444+func (*resizePTY) ForegroundPgid() (int, error) { return 0, nil }
A internal/usecase/daemon/floating_test.go +607 -0
@@ -0,0 +1,607 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "log/slog"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/ports"
18+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
19+ scopy "github.com/bnema/vev/internal/usecase/copy"
20+ "github.com/bnema/vev/internal/usecase/layout"
21+ themeui "github.com/bnema/vev/internal/usecase/theme"
22+)
23+
24+type gatedFloatingOpen struct {
25+ command string
26+ args []string
27+ dir string
28+ size domain.Size
29+}
30+
31+func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsmocks.MockPTYFactory, <-chan gatedFloatingOpen, func()) {
32+ t.Helper()
33+ factory := portsmocks.NewMockPTYFactory(t)
34+ opened := make(chan gatedFloatingOpen, 1)
35+ release := make(chan struct{})
36+ var releaseOnce sync.Once
37+ releaseOpen := func() { releaseOnce.Do(func() { close(release) }) }
38+ t.Cleanup(releaseOpen)
39+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
40+ func(command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) {
41+ opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size}
42+ <-release
43+ return result, openErr
44+ }).Once()
45+ return factory, opened, releaseOpen
46+}
47+
48+func TestFloatingSlotTransitions(t *testing.T) {
49+ first := &pane{}
50+ second := &pane{}
51+ tests := []struct {
52+ name string
53+ run func(t *testing.T, tb *tab)
54+ }{
55+ {
56+ name: "warming double toggle retains hidden completion",
57+ run: func(t *testing.T, tb *tab) {
58+ tb.mu.Lock()
59+ generation := tb.beginFloatingWarmLocked(false)
60+ start, gotGeneration := tb.toggleFloatingLocked()
61+ if start || gotGeneration != generation || !tb.floating.desiredVisible {
62+ t.Fatalf("first warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible)
63+ }
64+ start, gotGeneration = tb.toggleFloatingLocked()
65+ if start || gotGeneration != generation || tb.floating.desiredVisible {
66+ t.Fatalf("second warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible)
67+ }
68+ if !tb.installFloatingLocked(first, generation) || tb.floating.state != floatingHidden || tb.floating.pane != first {
69+ t.Fatal("warming completion did not retain a hidden pane")
70+ }
71+ tb.mu.Unlock()
72+ },
73+ },
74+ {
75+ name: "open and hide retain same pane",
76+ run: func(t *testing.T, tb *tab) {
77+ tb.mu.Lock()
78+ generation := tb.beginFloatingWarmLocked(false)
79+ if !tb.installFloatingLocked(first, generation) {
80+ t.Fatal("install failed")
81+ }
82+ start, _ := tb.toggleFloatingLocked()
83+ if start || tb.floating.state != floatingVisible || tb.floating.pane != first {
84+ t.Fatal("hidden open did not retain pane")
85+ }
86+ tb.toggleFloatingLocked()
87+ if tb.floating.state != floatingHidden || tb.floating.pane != first {
88+ t.Fatal("visible hide did not retain pane")
89+ }
90+ tb.mu.Unlock()
91+ },
92+ },
93+ {
94+ name: "current launch failure clears desired visibility",
95+ run: func(t *testing.T, tb *tab) {
96+ tb.mu.Lock()
97+ generation := tb.beginFloatingWarmLocked(true)
98+ if !tb.failFloatingLocked(generation) || tb.floating.state != floatingUninitialized || tb.floating.desiredVisible || tb.floating.pane != nil {
99+ t.Fatal("current launch failure did not clear slot")
100+ }
101+ tb.mu.Unlock()
102+ },
103+ },
104+ {
105+ name: "current exit clears and stale completion is ignored",
106+ run: func(t *testing.T, tb *tab) {
107+ tb.mu.Lock()
108+ generation := tb.beginFloatingWarmLocked(true)
109+ if !tb.installFloatingLocked(first, generation) {
110+ t.Fatal("install failed")
111+ }
112+ if !tb.clearFloatingLocked(first, generation) || tb.floating.state != floatingUninitialized || tb.floating.pane != nil {
113+ t.Fatal("current exit did not clear slot")
114+ }
115+ newGeneration := tb.beginFloatingWarmLocked(true)
116+ if tb.installFloatingLocked(second, generation) {
117+ t.Fatal("stale completion installed")
118+ }
119+ if tb.floating.state != floatingWarming || tb.floating.generation != newGeneration || tb.floating.pane != nil {
120+ t.Fatal("stale completion changed newer warming state")
121+ }
122+ tb.mu.Unlock()
123+ },
124+ },
125+ }
126+
127+ for _, tt := range tests {
128+ t.Run(tt.name, func(t *testing.T) { tt.run(t, &tab{}) })
129+ }
130+}
131+
132+func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) {
133+ pty := portsmocks.NewMockPTY(t)
134+ readerStarted := make(chan struct{})
135+ release := make(chan struct{})
136+ var releaseOnce sync.Once
137+ unblock := func() { releaseOnce.Do(func() { close(release) }) }
138+ pty.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
139+ close(readerStarted)
140+ <-release
141+ return 0, context.Canceled
142+ }).Once()
143+ pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once()
144+ factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)
145+ d := newTestDaemon(t, factory, stubClock{})
146+ d.shell = "/bin/custom-shell"
147+ cwd := t.TempDir()
148+ tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
149+ tb.ctx, tb.cancel = context.WithCancel(t.Context())
150+ sess := &session{name: "work", cwd: cwd, tabs: []*tab{tb}, ctx: t.Context()}
151+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
152+ d.ensureFloatingWarm(sess, tb)
153+ // Open has started while this goroutine owns tab.mu: an external factory
154+ // call under that lock would deadlock this channel-controlled test.
155+ tb.mu.Lock()
156+ var openCall gatedFloatingOpen
157+ select {
158+ case openCall = <-opened:
159+ case <-time.After(time.Second):
160+ t.Fatal("floating Open waited for tab.mu")
161+ }
162+ tb.mu.Unlock()
163+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "later", Width: 90, Height: 90}})
164+ allowOpen()
165+ select {
166+ case <-readerStarted: // install started exactly one reader after the async Open
167+ case <-time.After(time.Second):
168+ t.Fatal("floating pane was not installed")
169+ }
170+ tb.mu.Lock()
171+ floating := tb.floating.pane
172+ tb.mu.Unlock()
173+ require.NotNil(t, floating)
174+ assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
175+ assertPaneColorScheme(t, floating, false)
176+ require.Equal(t, "/bin/custom-shell", openCall.command)
177+ require.Equal(t, []string{"-lc", "btop --utf"}, openCall.args)
178+ require.Equal(t, cwd, openCall.dir)
179+ require.Equal(t, domain.Size{Cols: 48, Rows: 18}, openCall.size)
180+ d.teardownFloating(tb, nil)
181+ unblock()
182+ d.sessWg.Wait()
183+}
184+
185+func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) {
186+ floatingPTY := portsmocks.NewMockPTY(t)
187+ readerStarted := make(chan struct{})
188+ readerRelease := make(chan struct{})
189+ var releaseOnce sync.Once
190+ releaseReader := func() { releaseOnce.Do(func() { close(readerRelease) }) }
191+ var closeCount int
192+ floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
193+ close(readerStarted)
194+ <-readerRelease
195+ return 0, io.EOF
196+ }).Once()
197+ floatingPTY.EXPECT().Close().RunAndReturn(func() error {
198+ closeCount++
199+ releaseReader()
200+ return nil
201+ }).Once()
202+ factory, opened, releaseOpen := newGatedOpenFactory(t, floatingPTY, nil)
203+ d := newTestDaemon(t, factory, stubClock{})
204+ tb := newFloatingTestTab(t)
205+ sessCtx, cancel := context.WithCancel(t.Context())
206+ sess := &session{id: "floating-ownership", name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancel}
207+ d.mu.Lock()
208+ d.sessions[sess.id] = sess
209+ d.mu.Unlock()
210+
211+ // Keep a known worker alive while the waiter starts. Releasing it after
212+ // Open is blocked deterministically exposes an uncounted launch worker.
213+ d.sessWg.Add(1)
214+ joined := waitGroupDone(&d.sessWg)
215+ d.startFloating(sess, tb, true)
216+ select {
217+ case <-opened:
218+ case <-time.After(time.Second):
219+ t.Fatal("floating Open did not start")
220+ }
221+ d.sessWg.Done()
222+ // Waiting while Open is blocked must retain the launch worker. Otherwise
223+ // daemon shutdown can finish and a late install can Add reader goroutines.
224+ launchUnaccounted := false
225+ select {
226+ case <-joined:
227+ launchUnaccounted = true
228+ case <-time.After(time.Second):
229+ }
230+ releaseOpen()
231+ select {
232+ case <-readerStarted:
233+ case <-time.After(time.Second):
234+ t.Fatal("floating reader did not start")
235+ }
236+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
237+ select {
238+ case <-waitGroupDone(&d.sessWg):
239+ case <-time.After(time.Second):
240+ t.Fatal("floating launch, reader, and scheduler did not join")
241+ }
242+ tb.mu.Lock()
243+ require.Nil(t, tb.floating.pane, "shutdown leaked an installed floating pane")
244+ require.Equal(t, floatingUninitialized, tb.floating.state)
245+ tb.mu.Unlock()
246+ require.False(t, launchUnaccounted, "blocked floating launch was not accounted by sessWg")
247+ require.Equal(t, 1, closeCount, "floating PTY must close exactly once")
248+}
249+
250+func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *testing.T) {
251+ pty := portsmocks.NewMockPTY(t)
252+ closed := make(chan struct{})
253+ pty.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
254+ factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)
255+ d := newTestDaemon(t, factory, stubClock{})
256+ tb := newFloatingTestTab(t)
257+ sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()}
258+ d.ensureFloatingWarm(sess, tb)
259+ <-opened
260+ d.teardownFloating(tb, nil) // invalidate before the delayed Open returns
261+ allowOpen()
262+ select {
263+ case <-closed:
264+ case <-time.After(time.Second):
265+ t.Fatal("stale completion did not close its PTY")
266+ }
267+ d.sessWg.Wait()
268+
269+ first := &pane{}
270+ second := &pane{}
271+ tb.mu.Lock()
272+ g1 := tb.beginFloatingWarmLocked(true)
273+ require.True(t, tb.installFloatingLocked(first, g1))
274+ g2 := tb.clearFloatingLocked(first, g1)
275+ require.True(t, g2)
276+ generation := tb.beginFloatingWarmLocked(true)
277+ require.True(t, tb.installFloatingLocked(second, generation))
278+ tb.mu.Unlock()
279+ d.reapFloating(sess, tb, first, g1)
280+ tb.mu.Lock()
281+ require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement")
282+ tb.mu.Unlock()
283+}
284+
285+func TestFloatingReapAndTeardownCloseMatchingPaneOnce(t *testing.T) {
286+ pty, _ := newBlockingPTY(t)
287+ tb := newFloatingTestTab(t)
288+ p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
289+ tb.mu.Lock()
290+ g := tb.beginFloatingWarmLocked(true)
291+ require.True(t, tb.installFloatingLocked(p, g))
292+ tb.mu.Unlock()
293+ d := newTestDaemon(t, nil, stubClock{})
294+ d.reapFloating(&session{}, tb, p, g)
295+ d.teardownFloating(tb, nil)
296+ pty.AssertNumberOfCalls(t, "Close", 1)
297+}
298+
299+func TestFloatingTeardownClosesInstalledPaneOnce(t *testing.T) {
300+ pty, _ := newBlockingPTY(t)
301+ tb := newFloatingTestTab(t)
302+ p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
303+ tb.mu.Lock()
304+ g := tb.beginFloatingWarmLocked(false)
305+ require.True(t, tb.installFloatingLocked(p, g))
306+ tb.mu.Unlock()
307+ d := newTestDaemon(t, nil, stubClock{})
308+ d.teardownFloating(tb, nil)
309+ d.teardownFloating(tb, nil)
310+ pty.AssertNumberOfCalls(t, "Close", 1)
311+}
312+
313+func TestFloatingTeardownClearsCapturedCopyMode(t *testing.T) {
314+ pty, _ := newBlockingPTY(t)
315+ tb := newFloatingTestTab(t)
316+ p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
317+ tb.mu.Lock()
318+ generation := tb.beginFloatingWarmLocked(true)
319+ require.True(t, tb.installFloatingLocked(p, generation))
320+ tb.mu.Unlock()
321+ ac := &attachedClient{}
322+ ac.initOverlays()
323+ ac.overlays.copyMu.Lock()
324+ ac.overlays.copyMode = &scopy.Mode{}
325+ ac.overlays.copyPane = p
326+ ac.overlays.copyMu.Unlock()
327+ d := newTestDaemon(t, nil, stubClock{})
328+
329+ d.teardownFloating(tb, ac)
330+
331+ require.False(t, ac.overlays.copyActive())
332+ ac.overlays.copyMu.Lock()
333+ require.Nil(t, ac.overlays.copyPane)
334+ ac.overlays.copyMu.Unlock()
335+}
336+
337+func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
338+ newCase := func(t *testing.T, state floatingState) (*Daemon, *session, *attachedClient, chan ports.Frame, *tab, *pane, func()) {
339+ t.Helper()
340+ normalPTY, releaseNormal := newBlockingPTY(t)
341+ d, sess, ac, sends := newManualSessionWithPTYs(t, normalPTY)
342+ tb := sess.activeTab()
343+ require.NotNil(t, tb)
344+ tb.mu.Lock()
345+ normal := tb.focusedPane()
346+ normal.mu.Lock()
347+ normal.screen.Write([]byte("underlying-cell"))
348+ normal.mu.Unlock()
349+
350+ floatingPTY := portsmocks.NewMockPTY(t)
351+ floatingPTY.EXPECT().Close().Return(nil).Maybe()
352+ releaseEOF := make(chan struct{})
353+ floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
354+ <-releaseEOF
355+ return 0, io.EOF
356+ }).Once()
357+ floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})
358+ generation := tb.beginFloatingWarmLocked(state == floatingVisible)
359+ require.True(t, tb.installFloatingLocked(floating, generation))
360+ if state == floatingHidden {
361+ require.Equal(t, floatingHidden, tb.floating.state)
362+ }
363+ tb.mu.Unlock()
364+
365+ // Establish a renderer shadow first. The EOF repaint must reset it and
366+ // redraw the underlying cell rather than depending on popup damage.
367+ d.paint(sess, ac, true)
368+ baseline := awaitFrame(t, sends, ports.MsgOutput)
369+ baselineOutput, err := ports.UnmarshalOutput(baseline.Payload)
370+ require.NoError(t, err)
371+ require.Contains(t, string(baselineOutput.Data), "underlying-cell")
372+
373+ reaped := make(chan struct{})
374+ floating.onExit = func() {
375+ d.reapFloating(sess, tb, floating, generation)
376+ close(reaped)
377+ }
378+ d.sessWg.Add(1)
379+ go d.ptyReader(sess, tb, floating)
380+ return d, sess, ac, sends, tb, floating, func() {
381+ close(releaseEOF)
382+ <-reaped
383+ d.sessWg.Wait()
384+ releaseNormal()
385+ }
386+ }
387+
388+ t.Run("visible matching EOF clears and fully repaints underlying content", func(t *testing.T) {
389+ _, _, _, sends, tb, floating, exit := newCase(t, floatingVisible)
390+ exit()
391+
392+ tb.mu.Lock()
393+ require.Equal(t, floatingUninitialized, tb.floating.state)
394+ require.Nil(t, tb.floating.pane)
395+ tb.mu.Unlock()
396+ repaint := awaitFrame(t, sends, ports.MsgOutput)
397+ output, err := ports.UnmarshalOutput(repaint.Payload)
398+ require.NoError(t, err)
399+ require.Zero(t, output.BaseStateNum, "visible EOF must force a dependency-free full repaint")
400+ require.Contains(t, string(output.Data), "underlying-cell", "repaint must restore cells previously covered by the popup")
401+ require.NotNil(t, floating)
402+ })
403+
404+ t.Run("hidden matching EOF clears without repaint", func(t *testing.T) {
405+ _, _, _, sends, tb, _, exit := newCase(t, floatingHidden)
406+ exit()
407+
408+ tb.mu.Lock()
409+ require.Equal(t, floatingUninitialized, tb.floating.state)
410+ require.Nil(t, tb.floating.pane)
411+ tb.mu.Unlock()
412+ select {
413+ case frame := <-sends:
414+ t.Fatalf("hidden EOF unexpectedly repainted: %#v", frame)
415+ default:
416+ }
417+ })
418+
419+ t.Run("stale EOF is ignored without repaint", func(t *testing.T) {
420+ _, _, _, sends, tb, stale, exit := newCase(t, floatingHidden)
421+ tb.mu.Lock()
422+ generation := tb.floating.generation
423+ require.True(t, tb.clearFloatingLocked(stale, generation))
424+ current := newPane(layout.PaneID("replacement"), newBlockingPanePTY(t), domain.Size{Cols: 20, Rows: 8})
425+ currentGeneration := tb.beginFloatingWarmLocked(true)
426+ require.True(t, tb.installFloatingLocked(current, currentGeneration))
427+ tb.mu.Unlock()
428+ exit()
429+
430+ tb.mu.Lock()
431+ require.Same(t, current, tb.floating.pane)
432+ require.Equal(t, floatingVisible, tb.floating.state)
433+ tb.mu.Unlock()
434+ select {
435+ case frame := <-sends:
436+ t.Fatalf("stale EOF unexpectedly repainted: %#v", frame)
437+ default:
438+ }
439+ })
440+}
441+
442+func newFloatingTestTab(t *testing.T) *tab {
443+ t.Helper()
444+ tb := newTabWithStableID("t_test", "p_test", newBlockingPanePTY(t), domain.Size{Cols: 80, Rows: 24})
445+ tb.ctx, tb.cancel = context.WithCancel(t.Context())
446+ return tb
447+}
448+
449+func newBlockingPanePTY(t *testing.T) *portsmocks.MockPTY {
450+ t.Helper()
451+ p, _ := newBlockingPTY(t)
452+ return p
453+}
454+
455+func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) {
456+ tests := []struct {
457+ name string
458+ sessionCwd string
459+ liveCwd string
460+ wantCwd string
461+ }{
462+ {name: "valid session cwd", sessionCwd: "/valid", wantCwd: "/valid"},
463+ {name: "invalid session cwd falls home", sessionCwd: "/invalid", wantCwd: "/home/test"},
464+ {name: "live focused cwd", sessionCwd: "/invalid", liveCwd: "/live", wantCwd: "/live"},
465+ }
466+ for _, tt := range tests {
467+ t.Run(tt.name, func(t *testing.T) {
468+ factory := portsmocks.NewMockPTYFactory(t)
469+ opened := make(chan string, 1)
470+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
471+ func(_ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) {
472+ opened <- cwd
473+ return nil, io.ErrUnexpectedEOF
474+ }).Once()
475+ d := newTestDaemon(t, factory, stubClock{})
476+ d.dirOrHome = func(cwd string) string {
477+ if cwd == "/invalid" {
478+ return "/home/test"
479+ }
480+ return cwd
481+ }
482+ if tt.liveCwd != "" {
483+ d.procCwd = func(int) (string, error) { return tt.liveCwd, nil }
484+ }
485+ tb := newFloatingTestTab(t)
486+ sess := &session{name: "work", cwd: tt.sessionCwd, tabs: []*tab{tb}, ctx: t.Context()}
487+ d.startFloating(sess, tb, false)
488+ select {
489+ case got := <-opened:
490+ require.Equal(t, tt.wantCwd, got)
491+ case <-time.After(time.Second):
492+ t.Fatal("floating PTY was not opened")
493+ }
494+ d.sessWg.Wait()
495+ })
496+ }
497+}
498+
499+type floatingLogHandler struct {
500+ slog.Handler
501+ handled chan struct{}
502+}
503+
504+func (h floatingLogHandler) Handle(ctx context.Context, r slog.Record) error {
505+ err := h.Handler.Handle(ctx, r)
506+ close(h.handled)
507+ return err
508+}
509+
510+func TestFloatingLaunchFailureCapturesSessionNameBeforeConcurrentRename(t *testing.T) {
511+ factory, opened, release := newGatedOpenFactory(t, nil, io.ErrUnexpectedEOF)
512+ var logs bytes.Buffer
513+ handled := make(chan struct{})
514+ d := newTestDaemon(t, factory, stubClock{})
515+ d.log = slog.New(floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled})
516+ tb := newFloatingTestTab(t)
517+ sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()}
518+ d.startFloating(sess, tb, false)
519+ <-opened
520+ stopRename := make(chan struct{})
521+ renameDone := make(chan struct{})
522+ go func() {
523+ defer close(renameDone)
524+ for {
525+ select {
526+ case <-stopRename:
527+ return
528+ default:
529+ }
530+ sess.mu.Lock()
531+ sess.name = "renamed"
532+ sess.mu.Unlock()
533+ }
534+ }()
535+ release()
536+ select {
537+ case <-handled:
538+ case <-time.After(time.Second):
539+ t.Fatal("floating launch failure was not logged")
540+ }
541+ close(stopRename)
542+ <-renameDone
543+ d.sessWg.Wait()
544+ require.True(t, strings.Contains(logs.String(), "session=captured"), logs.String())
545+}
546+
547+func TestFloatingInstallLogsSessionNameSafelyDuringRename(t *testing.T) {
548+ floatingPTY, _ := newBlockingPTY(t)
549+ factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil)
550+ var logs bytes.Buffer
551+ handled := make(chan struct{})
552+ d := newTestDaemon(t, factory, stubClock{})
553+ d.log = slog.New(floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled})
554+ tb := newFloatingTestTab(t)
555+ sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()}
556+
557+ d.startFloating(sess, tb, false)
558+ <-opened
559+ renamed := make(chan struct{})
560+ stopRename := make(chan struct{})
561+ renameDone := make(chan struct{})
562+ go func() {
563+ defer close(renameDone)
564+ sess.mu.Lock()
565+ sess.name = "renamed"
566+ sess.mu.Unlock()
567+ close(renamed)
568+ for {
569+ select {
570+ case <-stopRename:
571+ return
572+ default:
573+ }
574+ sess.mu.Lock()
575+ sess.name = "renamed"
576+ sess.mu.Unlock()
577+ }
578+ }()
579+ <-renamed
580+ allowOpen()
581+ select {
582+ case <-handled:
583+ case <-time.After(time.Second):
584+ t.Fatal("installed floating pane was not logged")
585+ }
586+ close(stopRename)
587+ <-renameDone
588+ require.Contains(t, logs.String(), "session=renamed", logs.String())
589+
590+ d.teardownFloating(tb, nil)
591+ d.sessWg.Wait()
592+}
593+
594+func TestFloatingToggleUsesVisibleTarget(t *testing.T) {
595+ d := newTestDaemon(t, nil, stubClock{})
596+ tb := newFloatingTestTab(t)
597+ sess := &session{tabs: []*tab{tb}, ctx: t.Context()}
598+ tb.mu.Lock()
599+ g := tb.beginFloatingWarmLocked(false)
600+ floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8})
601+ require.True(t, tb.installFloatingLocked(floating, g))
602+ tb.mu.Unlock()
603+ require.NoError(t, d.toggleFloating(sess, nil))
604+ tb.mu.Lock()
605+ require.Same(t, floating, tb.terminalTargetLocked())
606+ tb.mu.Unlock()
607+}
M internal/usecase/daemon/input.go +52 -1
@@ -74,6 +74,30 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
7474 if rt.copySearchActive() {
7575 return
7676 }
77+ copyPane := copyTargetPane(rt)
78+ tb.mu.Lock()
79+ floatingPane, geometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
80+ floatingCopy := floatingVisible && copyPane == floatingPane
81+ tb.mu.Unlock()
82+ if floatingCopy {
83+ contentRow := ev.Row - 1
84+ if !pointInRect(ev.Col, contentRow, geometry.Inner) {
85+ return
86+ }
87+ // Match the multi-pane translation: hand copyMouse a zero-based
88+ // popup row so a click selects exactly the row under the pointer.
89+ ev.Row = contentRow
90+ ev = translateMouseEvent(ev, geometry.Inner.X, geometry.Inner.Y)
91+ switch ev.Button {
92+ case mouse.Left:
93+ d.copyMouse(sess, ac, ev)
94+ case mouse.WheelUp:
95+ d.copyWheel(sess, ac, -3)
96+ case mouse.WheelDown:
97+ d.copyWheel(sess, ac, 3)
98+ }
99+ return
100+ }
77101 if ev.Button == mouse.Left {
78102 contentRow := ev.Row - 1
79103 tb.mu.Lock()
@@ -120,6 +144,17 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
120144
121145 tb.mu.Lock()
122146 contentRow := ev.Row - 1
147+ floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
148+ if floatingVisible {
149+ if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
150+ tb.mu.Unlock()
151+ return
152+ }
153+ tb.mu.Unlock()
154+ ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y)
155+ d.handleTerminalMouse(sess, ac, floating, ev, true, true)
156+ return
157+ }
123158 pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)
124159 multi := len(tb.panes) > 1
125160 focusedID := layout.PaneID("")
@@ -177,6 +212,14 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
177212 return
178213 }
179214 }
215+ d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused)
216+}
217+
218+func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) {
219+ if p == nil {
220+ return
221+ }
222+ rt := ac.overlays
180223 p.mu.Lock()
181224 childRows := p.screen.Frame.Height
182225 mouseMode, mouseSGR := p.screen.MouseMode()
@@ -236,6 +279,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
236279 mode.StartSelectionAt(snap, pressTop+pressRow)
237280 mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row)
238281 rt.copyMode = mode
282+ rt.copyPane = p
239283 rt.copyPressRow = pressTop + pressRow
240284 rt.copyPressRowValid = true
241285 rt.copyDragging = true
@@ -285,7 +329,9 @@ func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) {
285329 if _, err := p.pty.Write(data); err != nil {
286330 name := ""
287331 if sess != nil {
332+ sess.mu.Lock()
288333 name = sess.name
334+ sess.mu.Unlock()
289335 }
290336 d.log.Error("pty write failed", "err", err, "session", name)
291337 }
@@ -348,7 +394,7 @@ func (h daemonKeyHandler) Forward(data []byte) {
348394 return
349395 }
350396 tb.mu.Lock()
351- p := tb.focusedPane()
397+ p := tb.terminalTargetLocked()
352398 tb.mu.Unlock()
353399 h.d.writeToPane(sess, p, data)
354400 }
@@ -363,6 +409,10 @@ func (h daemonKeyHandler) Action(action keys.Action) {
363409 h.d.enterPalette(sess, h.ac)
364410 case keys.ActionJumpAttention:
365411 h.d.jumpAttention(sess, h.ac)
412+ case keys.ActionToggleFloatingPane:
413+ if err := h.d.toggleFloating(sess, h.ac); err != nil {
414+ h.d.log.Warn("toggle floating pane failed", "err", err)
415+ }
366416 case keys.ActionFocusPaneLeft:
367417 _ = h.d.focusDir(sess, h.ac, layout.Left)
368418 case keys.ActionFocusPaneRight:
@@ -376,6 +426,7 @@ func (h daemonKeyHandler) Action(action keys.Action) {
376426 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
377427 idx := int(action - keys.ActionSwitchTab1)
378428 if sess.switchTab(idx) {
429+ h.d.activateTab(sess, sess.activeTab())
379430 h.d.paint(sess, h.ac, true)
380431 }
381432 }
M internal/usecase/daemon/input_test.go +179 -0
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "encoding/base64"
5+ "fmt"
56 "strings"
67 "sync"
78 "testing"
@@ -14,6 +15,7 @@ import (
1415 "github.com/bnema/vev/internal/ports"
1516 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1617 scopy "github.com/bnema/vev/internal/usecase/copy"
18+ "github.com/bnema/vev/internal/usecase/keys"
1719 "github.com/bnema/vev/internal/usecase/layout"
1820 )
1921
@@ -60,6 +62,22 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
6062 require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond)
6163 require.Equal(t, []byte("C"), <-writes3)
6264
65+ d.mu.Lock()
66+ var sess *session
67+ for _, candidate := range d.sessions {
68+ sess = candidate
69+ break
70+ }
71+ d.mu.Unlock()
72+ require.NotNil(t, sess)
73+ sess.mu.Lock()
74+ tabs := append([]*tab(nil), sess.tabs...)
75+ sess.mu.Unlock()
76+ require.Len(t, tabs, 3)
77+ for _, tb := range tabs {
78+ requireFloatingInitialized(t, tb)
79+ }
80+
6381 releaseConn()
6482 releasePTY1()
6583 releasePTY2()
@@ -79,6 +97,157 @@ func TestAltCForwardsToPTY(t *testing.T) {
7997 releasePTY()
8098 }
8199
100+func requireFloatingInitialized(t *testing.T, tb *tab) {
101+ t.Helper()
102+ tb.mu.Lock()
103+ defer tb.mu.Unlock()
104+ require.NotEqual(t, floatingUninitialized, tb.floating.state)
105+}
106+
107+func installTestFloating(tb *tab, p *pane, visible bool) {
108+ tb.mu.Lock()
109+ defer tb.mu.Unlock()
110+ tb.floating.pane = p
111+ tb.floating.generation++
112+ if visible {
113+ tb.floating.state = floatingVisible
114+ } else {
115+ tb.floating.state = floatingHidden
116+ }
117+}
118+
119+func TestFloatingKeyboardRoutesVisibleAndHiddenInput(t *testing.T) {
120+ for _, tc := range []struct {
121+ name string
122+ visible bool
123+ input []byte
124+ }{
125+ {name: "visible ordinary bytes", visible: true, input: []byte("hello")},
126+ {name: "visible escape", visible: true, input: []byte("\x1b")},
127+ {name: "hidden ordinary bytes", visible: false, input: []byte("hello")},
128+ {name: "hidden escape", visible: false, input: []byte("\x1b")},
129+ } {
130+ t.Run(tc.name, func(t *testing.T) {
131+ normalWrites := make(chan []byte, 1)
132+ floatingWrites := make(chan []byte, 1)
133+ normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
134+ floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
135+ defer releaseNormal()
136+ defer releaseFloating()
137+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
138+ installTestFloating(sess.activeTab(), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), tc.visible)
139+
140+ daemonKeyHandler{d: d, ac: ac}.Forward(tc.input)
141+ if tc.visible {
142+ requirePTYWrite(t, floatingWrites, tc.input)
143+ requireNoPTYWrite(t, normalWrites)
144+ } else {
145+ requirePTYWrite(t, normalWrites, tc.input)
146+ requireNoPTYWrite(t, floatingWrites)
147+ }
148+ })
149+ }
150+}
151+
152+func TestFloatingBracketedPasteAndGlobalActions(t *testing.T) {
153+ normalWrites := make(chan []byte, 1)
154+ floatingWrites := make(chan []byte, 1)
155+ normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
156+ floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
157+ defer releaseNormal()
158+ defer releaseFloating()
159+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
160+ installTestFloating(sess.activeTab(), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true)
161+
162+ paste := []byte("\x1b[200~paste\x1bf\x1b[201~")
163+ d.handleInput(sess, ac, paste)
164+ requirePTYWrite(t, floatingWrites, paste)
165+ d.handleInput(sess, ac, []byte("\x1b "))
166+ require.True(t, ac.overlays.paletteActive(), "global palette action must still intercept")
167+ requireNoPTYWrite(t, normalWrites)
168+}
169+
170+func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) {
171+ writes := make(chan []byte, 1)
172+ floatingWrites := make(chan []byte, 1)
173+ normal, releaseNormal := newBlockingPTYWithWrites(t, writes)
174+ floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites)
175+ defer releaseNormal()
176+ defer releaseFloating()
177+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
178+ tb := sess.activeTab()
179+ second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5})
180+ tb.mu.Lock()
181+ tb.size = domain.Size{Cols: 81, Rows: 5}
182+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
183+ tb.panes["pane-2"] = second
184+ tb.mu.Unlock()
185+ installTestFloating(tb, newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true)
186+
187+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneRight)
188+ require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
189+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x"))
190+ requirePTYWrite(t, floatingWrites, []byte("x"))
191+}
192+
193+func TestFloatingVisibilityRemainsIndependentAcrossTabSwitches(t *testing.T) {
194+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
195+ first := sess.activeTab()
196+ second := newTab(nil, first.size)
197+ installTestFloating(first, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
198+ installTestFloating(second, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), false)
199+ sess.mu.Lock()
200+ sess.tabs = append(sess.tabs, second)
201+ sess.mu.Unlock()
202+
203+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2)
204+ second.mu.Lock()
205+ require.Equal(t, floatingHidden, second.floating.state)
206+ second.mu.Unlock()
207+
208+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab1)
209+ first.mu.Lock()
210+ require.Equal(t, floatingVisible, first.floating.state)
211+ first.mu.Unlock()
212+}
213+
214+func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) {
215+ normal := portsmocks.NewMockPTY(t)
216+ floatingPTY := portsmocks.NewMockPTY(t)
217+ floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
218+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
219+ tb := sess.activeTab()
220+ tb.mu.Lock()
221+ tb.size = domain.Size{Cols: 100, Rows: 20}
222+ tb.mu.Unlock()
223+ floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14})
224+ floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
225+ installTestFloating(tb, floating, true)
226+ geometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 20}, d.currentFloatingConfig())
227+ raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2))
228+
229+ d.handleInput(sess, ac, raw)
230+}
231+
232+func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) {
233+ normal := portsmocks.NewMockPTY(t)
234+ floatingPTY := portsmocks.NewMockPTY(t)
235+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
236+ tb := sess.activeTab()
237+ tb.mu.Lock()
238+ tb.size = domain.Size{Cols: 100, Rows: 20}
239+ tb.mu.Unlock()
240+ floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14})
241+ floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
242+ installTestFloating(tb, floating, true)
243+ geometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 20}, d.currentFloatingConfig())
244+
245+ border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2))
246+ outside := []byte("\x1b[<0;1;2M")
247+ d.handleInput(sess, ac, border)
248+ d.handleInput(sess, ac, outside)
249+}
250+
82251 func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
83252 writes := make(chan []byte, 1)
84253 p, releasePTY := newBlockingPTYWithWrites(t, writes)
@@ -196,6 +365,16 @@ func TestOverlayInputPrecedence(t *testing.T) {
196365 }
197366 }
198367
368+func requirePTYWrite(t *testing.T, writes chan []byte, want []byte) {
369+ t.Helper()
370+ select {
371+ case got := <-writes:
372+ require.Equal(t, want, got)
373+ case <-time.After(time.Second):
374+ t.Fatalf("PTY did not receive %q", want)
375+ }
376+}
377+
199378 func requireNoPTYWrite(t *testing.T, writes chan []byte) {
200379 t.Helper()
201380 select {
M internal/usecase/daemon/overlay_runtime.go +17 -0
@@ -30,6 +30,7 @@ type overlayRuntime struct {
3030
3131 copyMu sync.Mutex
3232 copyMode *scopy.Mode
33+ copyPane *pane
3334 copyPending []byte
3435 copyESC pendingByteTimer
3536 copySearch *visualsearch.Model
@@ -101,10 +102,24 @@ func (rt *overlayRuntime) copySearchActive() bool {
101102
102103 func (rt *overlayRuntime) clearCopyModeLocked() {
103104 rt.copyMode = nil
105+ rt.copyPane = nil
104106 rt.copySearch = nil
105107 rt.copySearchPending = nil
106108 }
107109
110+func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
111+ if rt == nil || p == nil {
112+ return false
113+ }
114+ rt.copyMu.Lock()
115+ defer rt.copyMu.Unlock()
116+ if rt.copyMode == nil || rt.copyPane != p {
117+ return false
118+ }
119+ rt.clearCopyModeLocked()
120+ return true
121+}
122+
108123 func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
109124 if rt == nil || rt.ac == nil {
110125 return false
@@ -134,6 +149,7 @@ type overlayRenderSnapshot struct {
134149
135150 copyActive bool
136151 copyMode *scopy.Mode
152+ copyPane *pane
137153 copySearchModel *visualsearch.Model
138154 copyFeedback string
139155
@@ -164,6 +180,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
164180
165181 rt.copyMu.Lock()
166182 snap.copyActive = rt.copyMode != nil
183+ snap.copyPane = rt.copyPane
167184 if rt.copyMode != nil {
168185 copyModeValue := *rt.copyMode
169186 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
M internal/usecase/daemon/overlay_runtime_test.go +23 -0
@@ -5,6 +5,7 @@ import (
55
66 "github.com/stretchr/testify/require"
77
8+ "github.com/bnema/vev/internal/domain"
89 scopy "github.com/bnema/vev/internal/usecase/copy"
910 promptui "github.com/bnema/vev/internal/usecase/prompt"
1011 "github.com/bnema/vev/internal/usecase/visualsearch"
@@ -143,3 +144,25 @@ func TestOverlayRuntimeHandleInputInactive(t *testing.T) {
143144
144145 require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x")))
145146 }
147+
148+func TestOverlayRuntimeSnapshotCarriesCopyPaneAndClearReleasesIt(t *testing.T) {
149+ ac := &attachedClient{}
150+ ac.initOverlays()
151+ p := newPane("floating", nil, domain.Size{Cols: 4, Rows: 2})
152+ snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("row")}, Width: 4, Height: 2}
153+
154+ ac.overlays.copyMu.Lock()
155+ ac.overlays.copyMode = scopy.NewMode(snap)
156+ ac.overlays.copyPane = p
157+ ac.overlays.copyMu.Unlock()
158+
159+ renderSnap := ac.overlays.SnapshotForRender()
160+ require.Same(t, p, renderSnap.copyPane)
161+ renderSnap.Unlock()
162+
163+ ac.overlays.copyMu.Lock()
164+ ac.overlays.clearCopyModeLocked()
165+ require.Nil(t, ac.overlays.copyPane)
166+ require.Nil(t, ac.overlays.copyMode)
167+ ac.overlays.copyMu.Unlock()
168+}
M internal/usecase/daemon/palette.go +10 -2
@@ -230,17 +230,25 @@ func (e paletteExec) FocusPaneDown() error {
230230 }
231231
232232 func (e paletteExec) NextTab() error {
233- e.sess.switchRelative(1)
233+ if e.sess.switchRelative(1) {
234+ e.d.activateTab(e.sess, e.sess.activeTab())
235+ }
234236 e.d.paint(e.sess, e.ac, true)
235237 return nil
236238 }
237239
238240 func (e paletteExec) PrevTab() error {
239- e.sess.switchRelative(-1)
241+ if e.sess.switchRelative(-1) {
242+ e.d.activateTab(e.sess, e.sess.activeTab())
243+ }
240244 e.d.paint(e.sess, e.ac, true)
241245 return nil
242246 }
243247
248+func (e paletteExec) ToggleFloatingPane() error {
249+ return e.d.toggleFloating(e.sess, e.ac)
250+}
251+
244252 func (e paletteExec) BackSession() error {
245253 e.d.navigateRecentSession(e.sess, e.ac, 1)
246254 return nil
M internal/usecase/daemon/palette_test.go +29 -5
@@ -18,6 +18,10 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
1818 p1, release1 := newBlockingPTY(t)
1919 p2, release2 := newBlockingPTY(t)
2020 d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2)
21+ sess.mu.Lock()
22+ sess.client = ac
23+ sess.mu.Unlock()
24+ d.ptys = newBlockingOpenFactory(t, d)
2125 defer release1()
2226 defer release2()
2327
@@ -31,6 +35,7 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
3135 d.handleInput(sess, ac, []byte("NXT\r"))
3236 require.False(t, ac.overlays.paletteActive())
3337 require.Equal(t, 1, activeTabIndex(sess))
38+ requireFloatingInitialized(t, sess.activeTab())
3439 awaitFrame(t, sends, ports.MsgOutput)
3540
3641 d.handleInput(sess, ac, []byte("\x1b "))
@@ -40,15 +45,31 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
4045 awaitFrame(t, sends, ports.MsgOutput)
4146 }
4247
48+func TestPaletteFLTExecutesFloatingToggle(t *testing.T) {
49+ p, release := newBlockingPTY(t)
50+ defer release()
51+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
52+ tb := sess.activeTab()
53+ installTestFloating(tb, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), false)
54+
55+ d.handleInput(sess, ac, []byte("\x1b "))
56+ awaitFrame(t, sends, ports.MsgOutput)
57+ d.handleInput(sess, ac, []byte("FLT\r"))
58+
59+ require.False(t, ac.overlays.paletteActive())
60+ tb.mu.Lock()
61+ require.Equal(t, floatingVisible, tb.floating.state)
62+ tb.mu.Unlock()
63+ awaitFrame(t, sends, ports.MsgOutput)
64+}
65+
4366 func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
4467 p1, release1 := newBlockingPTY(t)
4568 p2, release2 := newBlockingPTY(t)
4669 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
4770 defer release1()
4871 defer release2()
49- ptys := portsmocks.NewMockPTYFactory(t)
50- ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p2, nil).Once()
51- d.ptys = ptys
72+ d.ptys = newFactorySeq(t, p2)
5273
5374 d.handleInput(sess, ac, []byte("\x1b "))
5475 awaitFrame(t, sends, ports.MsgOutput)
@@ -83,6 +104,9 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
83104
84105 func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
85106 d, sess, ac, sends, releases := newManualTabSession(t, 2)
107+ sess.mu.Lock()
108+ sess.client = ac
109+ sess.mu.Unlock()
86110 defer releases[0]()
87111 defer releases[1]()
88112
@@ -112,12 +136,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
112136 for i, cmd := range commands {
113137 codes[i] = cmd.Code
114138 }
115- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "RNT", "DET"}, codes)
139+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "RNT", "DET"}, codes)
116140 }
117141
118142 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
119143 d := &Daemon{}
120- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
144+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
121145
122146 var wg sync.WaitGroup
123147 for range 50 {
M internal/usecase/daemon/pane.go +20 -16
@@ -3,7 +3,6 @@ package daemon
33 import (
44 "context"
55 "sync"
6- "time"
76
87 "github.com/bnema/vev/internal/domain"
98 "github.com/bnema/vev/internal/ports"
@@ -17,21 +16,25 @@ import (
1716 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
1817 // The PTY reader takes only pane.mu, so child output never waits on client IO.
1918 type pane struct {
20- id layout.PaneID
21- stableID string
22- pty ports.PTY
23- mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
24- screen *vt.Screen
25- scrollback *scopy.Scrollback
26- dirty chan struct{}
27- flush chan struct{}
28- syncGen uint64
29- rect domain.Rect
30- title string
31- titleAt time.Time
32- titleValid bool
33- ctx context.Context
34- cancel context.CancelFunc
19+ id layout.PaneID
20+ stableID string
21+ pty ports.PTY
22+ mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
23+ resizeMu sync.Mutex // serializes PTY resizes without holding mu
24+ screen *vt.Screen
25+ scrollback *scopy.Scrollback
26+ dirty chan struct{}
27+ flush chan struct{}
28+ syncGen uint64
29+ rect domain.Rect
30+ popupGeometry floatingGeometry // last geometry committed after a successful floating resize
31+ title paneTitleState
32+ ctx context.Context
33+ cancel context.CancelFunc
34+ floatingCloseOnce sync.Once // used only by floating lifecycle teardown paths
35+ // onExit is set before the reader starts and never changed. Floating panes
36+ // use it to reap their independent slot without touching the layout tree.
37+ onExit func()
3538 }
3639
3740 func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
@@ -51,6 +54,7 @@ func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz do
5154 dirty: make(chan struct{}, 1),
5255 flush: make(chan struct{}, 1),
5356 rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
57+ title: paneTitleState{displayFallback: "sh"},
5458 }
5559 }
5660
M internal/usecase/daemon/pane_titles.go +93 -14
@@ -10,6 +10,69 @@ import (
1010
1111 const paneTitleCacheTTL = time.Second
1212
13+// paneTitleState is guarded by pane.mu. generation changes whenever the
14+// displayed title can change, allowing cached composition to damage only a
15+// title row rather than the entire stacked layout.
16+type paneTitleState struct {
17+ processName string
18+ processNameAt time.Time
19+ processNameValid bool
20+ terminalTitle string
21+ displayFallback string
22+ generation uint64
23+}
24+
25+func formatPaneTitle(processName, terminalTitle, fallback string) string {
26+ if processName != "" && terminalTitle != "" {
27+ return processName + ": " + terminalTitle
28+ }
29+ if processName != "" {
30+ return processName
31+ }
32+ if terminalTitle != "" {
33+ return terminalTitle
34+ }
35+ return fallback
36+}
37+
38+func (p *pane) formattedTitleLocked(fallback string) string {
39+ return formatPaneTitle(p.title.processName, p.title.terminalTitle, fallback)
40+}
41+
42+func (p *pane) displayTitleLocked() string {
43+ return p.formattedTitleLocked(p.title.displayFallback)
44+}
45+
46+// setDisplayFallback updates the fallback owned by a pane. It must not damage
47+// cached composition unless that fallback changes the displayed title.
48+func (p *pane) setDisplayFallback(fallback string) {
49+ p.mu.Lock()
50+ oldTitle := p.displayTitleLocked()
51+ p.title.displayFallback = fallback
52+ if oldTitle != p.displayTitleLocked() {
53+ p.title.generation++
54+ }
55+ p.mu.Unlock()
56+}
57+
58+// refreshTerminalTitleLocked synchronizes the title retained by the VT parser.
59+// It must be called while p.mu is held, including by ptyReader around Write.
60+func (p *pane) refreshTerminalTitleLocked() {
61+ title := p.screen.TerminalTitle()
62+ if p.title.terminalTitle != title {
63+ p.title.terminalTitle = title
64+ p.title.generation++
65+ }
66+}
67+
68+func (d *Daemon) paneTitleFallback() string {
69+ fallback := filepath.Base(d.shell)
70+ if fallback == "." || fallback == string(filepath.Separator) || fallback == "" {
71+ return d.shell
72+ }
73+ return fallback
74+}
75+
1376 func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {
1477 return d.refreshPaneTitleCached(sess, id, false)
1578 }
@@ -18,11 +81,9 @@ func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID) string
1881 return d.refreshPaneTitleCached(sess, id, true)
1982 }
2083
84+// refreshPaneTitleCached retains the ID lookup used by normal layout panes.
2185 func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool) string {
22- fallback := filepath.Base(d.shell)
23- if fallback == "." || fallback == string(filepath.Separator) || fallback == "" {
24- fallback = d.shell
25- }
86+ fallback := d.paneTitleFallback()
2687 if sess == nil {
2788 return fallback
2889 }
@@ -36,29 +97,47 @@ func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force b
3697 if p == nil {
3798 return fallback
3899 }
100+ p.setDisplayFallback(fallback)
101+ return d.refreshPaneDisplayTitle(sess, p, force)
102+}
103+
104+// refreshPaneDisplayTitle refreshes only the process-name portion of p's
105+// display title. It deliberately releases pane.mu before querying the PTY so
106+// callers that already render under pane locking cannot recurse on that lock.
107+func (d *Daemon) refreshPaneDisplayTitle(_ *session, p *pane, force bool) string {
108+ if p == nil {
109+ return d.paneTitleFallback()
110+ }
39111 now := d.clock.Now()
40112 p.mu.Lock()
41- if !force && p.titleValid && now.Sub(p.titleAt) < paneTitleCacheTTL {
42- title := p.title
113+ if !force && p.title.processNameValid && now.Sub(p.title.processNameAt) < paneTitleCacheTTL {
114+ title := p.displayTitleLocked()
43115 p.mu.Unlock()
44- if title == "" {
45- return fallback
46- }
47116 return title
48117 }
49118 p.mu.Unlock()
50- title := fallback
119+
120+ // A failed lookup means no process name was discovered. Keep that state
121+ // distinct from the display fallback: an OSC title must then stand alone,
122+ // and future floating panes can supply their command fallback at format time.
123+ processName := ""
51124 if d.procComm != nil && p.pty != nil {
52125 if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 {
53126 if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" {
54- title = strings.TrimSpace(comm)
127+ processName = strings.TrimSpace(comm)
55128 }
56129 }
57130 }
131+
58132 p.mu.Lock()
59- p.title = title
60- p.titleAt = now
61- p.titleValid = true
133+ oldTitle := p.displayTitleLocked()
134+ p.title.processName = processName
135+ p.title.processNameAt = now
136+ p.title.processNameValid = true
137+ title := p.displayTitleLocked()
138+ if oldTitle != title {
139+ p.title.generation++
140+ }
62141 p.mu.Unlock()
63142 return title
64143 }
M internal/usecase/daemon/pane_titles_test.go +111 -8
@@ -8,12 +8,47 @@ import (
88
99 "github.com/stretchr/testify/require"
1010
11+ "github.com/bnema/vev/internal/domain"
1112 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1213 )
1314
15+func TestFormatPaneTitle(t *testing.T) {
16+ tests := []struct {
17+ name, process, terminal, fallback, want string
18+ }{
19+ {name: "process and terminal", process: "nvim", terminal: "project/main.go", fallback: "sh", want: "nvim: project/main.go"},
20+ {name: "process only", process: "nvim", fallback: "sh", want: "nvim"},
21+ {name: "terminal only", terminal: "project/main.go", fallback: "sh", want: "project/main.go"},
22+ {name: "fallback", fallback: "fish", want: "fish"},
23+ }
24+ for _, tt := range tests {
25+ t.Run(tt.name, func(t *testing.T) {
26+ require.Equal(t, tt.want, formatPaneTitle(tt.process, tt.terminal, tt.fallback))
27+ })
28+ }
29+}
30+
31+func TestPaneTerminalTitleGenerationChangesOnlyWhenTitleChanges(t *testing.T) {
32+ p := newPane("pane", nil, domain.Size{Cols: 20, Rows: 5})
33+ p.mu.Lock()
34+ p.screen.Write([]byte("\x1b]2;project/main.go\a"))
35+ p.refreshTerminalTitleLocked()
36+ first := p.title.generation
37+ p.refreshTerminalTitleLocked()
38+ second := p.title.generation
39+ p.screen.Write([]byte("\x1b]0;other\a"))
40+ p.refreshTerminalTitleLocked()
41+ third := p.title.generation
42+ p.mu.Unlock()
43+
44+ require.Equal(t, uint64(1), first)
45+ require.Equal(t, first, second)
46+ require.Equal(t, first+1, third)
47+}
48+
1449 func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
1550 pty := portsmocks.NewMockPTY(t)
16- pty.EXPECT().ForegroundPgid().Return(1234, nil).Once()
51+ pty.EXPECT().ForegroundPgid().Return(1234, nil).Twice()
1752 _, sess, _, _ := newManualSessionWithPTYs(t, pty)
1853 clk := portsmocks.NewMockClock(t)
1954 clk.EXPECT().Now().Return(time.Time{}).Maybe()
@@ -25,9 +60,17 @@ func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
2560 }
2661
2762 title := d.refreshPaneTitle(sess, "pane-1")
28-
2963 require.Equal(t, "vim", title)
30- require.Equal(t, "vim", sess.activeTab().focusedPane().title)
64+
65+ p := sess.activeTab().focusedPane()
66+ p.mu.Lock()
67+ p.screen.Write([]byte("\x1b]2;project/main.go\a"))
68+ p.refreshTerminalTitleLocked()
69+ p.mu.Unlock()
70+ title = d.refreshPaneDisplayTitle(sess, p, true)
71+
72+ require.Equal(t, "vim: project/main.go", title)
73+ require.Equal(t, "vim", p.title.processName)
3174 }
3275
3376 func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {
@@ -53,18 +96,78 @@ func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {
5396 require.Equal(t, int32(2), calls.Load(), "focus refresh should bypass TTL")
5497 }
5598
56-func TestRefreshPaneTitleFallsBackToShellBase(t *testing.T) {
99+func TestRefreshFloatingPaneTitlePreservesConfiguredCommandFallback(t *testing.T) {
100+ pty := portsmocks.NewMockPTY(t)
101+ pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Twice()
102+ clk := portsmocks.NewMockClock(t)
103+ clk.EXPECT().Now().Return(time.Time{}).Maybe()
104+ d := newTestDaemon(t, nil, clk)
105+ d.shell = "/usr/bin/fish"
106+ d.procComm = func(int) (string, error) { return "", errors.New("unused") }
107+ p := newPane("floating", pty, domain.Size{Cols: 20, Rows: 5})
108+ p.title.displayFallback = floatingCommandFallback("btop --utf", d.shell)
109+
110+ require.Equal(t, "btop", d.refreshPaneDisplayTitle(nil, p, false))
111+ require.Equal(t, "btop", d.refreshPaneDisplayTitle(nil, p, false), "cached refresh must retain the pane-owned fallback")
112+ require.Equal(t, "btop", d.refreshPaneDisplayTitle(nil, p, true), "process refresh must retain the pane-owned fallback")
113+ p.mu.Lock()
114+ require.Equal(t, "btop", p.title.displayFallback)
115+ p.mu.Unlock()
116+}
117+
118+func TestRefreshFloatingPaneTitleUsesShellFallbackForEmptyCommand(t *testing.T) {
119+ d := newTestDaemon(t, nil, stubClock{})
120+ d.shell = "/usr/local/bin/zsh"
121+ p := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
122+ p.title.displayFallback = floatingCommandFallback("", d.shell)
123+
124+ require.Equal(t, "zsh", d.refreshPaneDisplayTitle(nil, p, true))
125+}
126+
127+func TestRefreshPaneTitleUpdatesNormalPaneShellFallback(t *testing.T) {
128+ d, sess, _, _ := newManualSessionWithPTYs(t, nil)
129+ p := sess.activeTab().focusedPane()
130+
131+ d.shell = "/usr/bin/fish"
132+ require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1"))
133+ p.mu.Lock()
134+ firstGeneration := p.title.generation
135+ p.mu.Unlock()
136+ require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1"))
137+ p.mu.Lock()
138+ require.Equal(t, firstGeneration, p.title.generation, "unchanged displayed title must not damage the title row")
139+ p.mu.Unlock()
140+
141+ d.shell = "/bin/zsh"
142+ require.Equal(t, "zsh", d.refreshPaneTitle(sess, "pane-1"))
143+ p.mu.Lock()
144+ require.Equal(t, firstGeneration+1, p.title.generation)
145+ p.mu.Unlock()
146+}
147+
148+func TestRefreshPaneTitleLookupFailureKeepsProcessNameEmpty(t *testing.T) {
57149 pty := portsmocks.NewMockPTY(t)
58- pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Once()
150+ pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Twice()
59151 _, sess, _, _ := newManualSessionWithPTYs(t, pty)
60152 clk := portsmocks.NewMockClock(t)
61153 clk.EXPECT().Now().Return(time.Time{}).Maybe()
62154 d := newTestDaemon(t, nil, clk)
63155 d.shell = "/usr/bin/fish"
64156 d.procComm = func(int) (string, error) { return "", errors.New("unused") }
157+ p := sess.activeTab().focusedPane()
65158
66- title := d.refreshPaneTitle(sess, "pane-1")
159+ p.mu.Lock()
160+ p.screen.Write([]byte("\x1b]2;terminal\a"))
161+ p.refreshTerminalTitleLocked()
162+ p.mu.Unlock()
163+ require.Equal(t, "terminal", d.refreshPaneDisplayTitle(sess, p, true))
164+ p.mu.Lock()
165+ require.Empty(t, p.title.processName, "a lookup failure must not become process state")
166+ p.mu.Unlock()
67167
68- require.Equal(t, "fish", title)
69- require.Equal(t, "fish", sess.activeTab().focusedPane().title)
168+ p.mu.Lock()
169+ p.screen.Write([]byte("\x1b]2;\a"))
170+ p.refreshTerminalTitleLocked()
171+ p.mu.Unlock()
172+ require.Equal(t, "sh", d.refreshPaneDisplayTitle(sess, p, true), "pointer refresh must retain its pane-owned fallback")
70173 }
M internal/usecase/daemon/picker.go +3 -1
@@ -360,7 +360,9 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
360360 return
361361 }
362362 if targetSess == sess {
363- sess.switchTab(target.TabIndex)
363+ if sess.switchTab(target.TabIndex) {
364+ d.activateTab(sess, sess.activeTab())
365+ }
364366 d.paint(sess, ac, true)
365367 return
366368 }
M internal/usecase/daemon/picker_test.go +29 -7
@@ -90,6 +90,10 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
9090
9191 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
9292 d, sess, ac, sends, releases := newManualTabSession(t, 2)
93+ sess.mu.Lock()
94+ sess.client = ac
95+ sess.mu.Unlock()
96+ d.ptys = newBlockingOpenFactory(t, d)
9397 defer func() {
9498 for _, release := range releases {
9599 release()
@@ -103,6 +107,7 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
103107 d.handleInput(sess, ac, []byte("\r"))
104108
105109 require.Equal(t, 1, activeTabIndex(sess))
110+ requireFloatingInitialized(t, sess.activeTab())
106111 awaitFrame(t, sends, ports.MsgOutput)
107112 d.enterPicker(sess, ac)
108113 awaitFrame(t, sends, ports.MsgOutput)
@@ -315,11 +320,14 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
315320 var openedEnv []string
316321 f := portsmocks.NewMockPTYFactory(t)
317322 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
318- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
323+ func(_ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {
324+ if sz != (domain.Size{Cols: 80, Rows: 22}) {
325+ return newQuietPTY(), nil
326+ }
319327 openedEnv = append([]string(nil), env...)
320328 return p3, nil
321329 },
322- ).Once()
330+ ).Maybe()
323331 d := newTestDaemon(t, f, stubClock{})
324332 tr1, _ := newCapturingTransport(t)
325333 tr2, _ := newCapturingTransport(t)
@@ -362,12 +370,12 @@ func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {
362370 func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
363371 tb := newTab(nil, domain.Size{Cols: 41, Rows: 5})
364372 left := tb.focusedPane()
365- left.title = "one"
373+ left.title.processName = "one"
366374 left.screen.Write([]byte("L"))
367375 rightTop := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
368- rightTop.title = "two"
376+ rightTop.title.processName = "two"
369377 rightBottom := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 2})
370- rightBottom.title = "three"
378+ rightBottom.title.processName = "three"
371379 rightBottom.screen.Write([]byte("R"))
372380
373381 tb.mu.Lock()
@@ -532,7 +540,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
532540 defer release2()
533541 var opens [][]string
534542 f := portsmocks.NewMockPTYFactory(t)
535- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
543+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
544+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
536545 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
537546 opens = append(opens, append([]string(nil), env...))
538547 if len(opens) == 1 {
@@ -541,6 +550,10 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
541550 return p2, nil
542551 },
543552 ).Twice()
553+ floating := newQuietPTY()
554+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
555+ return got != normalSize && got.Valid()
556+ })).Return(floating, nil).Once()
544557 d := newTestDaemon(t, f, stubClock{})
545558 d.stopped["old"] = stoppedSession{name: "old", cwd: t.TempDir(), createdAt: 1}
546559 tr := portsmocks.NewMockTransport(t)
@@ -552,10 +565,19 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
552565 got := ac.sess.Get()
553566 require.NotNil(t, got)
554567 got.mu.Lock()
555- defer got.mu.Unlock()
556568 require.True(t, got.terminal.TrueColor)
569+ got.mu.Unlock()
557570 require.Len(t, opens, 2)
558571 require.Contains(t, opens[1], "TERM=xterm-direct")
559572 require.Contains(t, opens[1], "COLORTERM=truecolor")
560573 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
574+ _ = d.killSession(got, ports.ReasonSessionKilled, false)
575+ release1()
576+ release2()
577+ d.sessWg.Wait()
578+ select {
579+ case <-floating.done:
580+ default:
581+ t.Fatal("floating prewarm PTY was not closed")
582+ }
561583 }
M internal/usecase/daemon/render.go +122 -26
@@ -69,6 +69,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
6969 p.mu.Lock()
7070 wasSyncing := p.screen.SyncUpdateActive()
7171 p.screen.Write(data)
72+ p.refreshTerminalTitleLocked()
7273 isSyncing := p.screen.SyncUpdateActive()
7374 p.mu.Unlock()
7475 markSnapshotDirty(sess)
@@ -108,7 +109,11 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
108109 signal(p.dirty)
109110 }
110111 if err != nil {
111- d.reapPane(sess, tb, p)
112+ if p.onExit != nil {
113+ p.onExit()
114+ } else {
115+ d.reapPane(sess, tb, p)
116+ }
112117 return
113118 }
114119 }
@@ -214,9 +219,6 @@ func syncUpdateEndIn(data []byte) bool {
214219 // render paints the current client, or (when detached) just clears accumulated
215220 // damage so it never grows unbounded while headless.
216221 func (d *Daemon) render(sess *session, tb *tab, p *pane) {
217- tb.mu.Lock()
218- previewer := tb.previewClient
219- tb.mu.Unlock()
220222 if p == nil {
221223 return
222224 }
@@ -227,6 +229,20 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) {
227229 }
228230 p.mu.Unlock()
229231
232+ // A retained floating pane continues to drain its PTY while hidden. Its
233+ // scheduler must not route through picker preview (or the active client),
234+ // but it must consume damage so a long-hidden command cannot retain it.
235+ tb.mu.Lock()
236+ hiddenFloating := tb.floating.pane == p && tb.floating.state != floatingVisible
237+ previewer := tb.previewClient
238+ tb.mu.Unlock()
239+ if hiddenFloating {
240+ p.mu.Lock()
241+ p.screen.ClearDamage()
242+ p.mu.Unlock()
243+ return
244+ }
245+
230246 sess.mu.Lock()
231247 ac := sess.client
232248 active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
@@ -259,6 +275,34 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) {
259275 // paint draws the composed client frame (active tab plus status bar) and
260276 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
261277 // such as switch/create/close/rename/resize so the repaint is complete.
278+// copyTargetRectLocked maps a captured copy source into the already-composed
279+// client frame. The caller holds tb.mu, preserving the layout/floating
280+// snapshot while the rectangle is chosen.
281+func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, cfg domain.FloatingConfig) domain.Rect {
282+ if p == nil {
283+ return domain.Rect{}
284+ }
285+ if hasFloating && p == floating {
286+ desired := calculateFloatingGeometry(contentArea, cfg)
287+ p.mu.Lock()
288+ geometry := p.committedFloatingGeometryLocked(desired)
289+ p.mu.Unlock()
290+ return geometry.Inner
291+ }
292+ for _, placement := range layoutSnap.placements {
293+ if placement.ID == p.id && !placement.Collapsed {
294+ r := placement.Content
295+ r.X += contentArea.X
296+ r.Y += contentArea.Y
297+ return r
298+ }
299+ }
300+ p.mu.Lock()
301+ width, height := p.screen.Frame.Width, p.screen.Frame.Height
302+ p.mu.Unlock()
303+ return domain.Rect{X: contentArea.X, Y: contentArea.Y, Width: min(width, contentArea.Width), Height: min(height, contentArea.Height)}
304+}
305+
262306 func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID {
263307 if !ok {
264308 return nil
@@ -313,10 +357,15 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
313357 tb.mu.Lock()
314358 layoutSnap := solveTabLayoutLocked(tb)
315359 titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok)
360+ floating := tb.floating.pane
361+ hasFloating := tb.floating.state == floatingVisible && floating != nil
316362 tb.mu.Unlock()
317363 for _, id := range titleIDs {
318364 d.refreshPaneTitle(sess, id)
319365 }
366+ if hasFloating {
367+ d.refreshPaneDisplayTitle(sess, floating, false)
368+ }
320369
321370 tb.mu.Lock()
322371 if !layoutSnap.matchesLocked(tb) {
@@ -328,6 +377,12 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
328377 ac.sendMu.Unlock()
329378 return
330379 }
380+ // Recheck the slot after refreshing its title outside the tab lock. One
381+ // paint uses one immutable floating config snapshot across composition,
382+ // copy targeting, and cursor placement.
383+ floating = tb.floating.pane
384+ hasFloating = tb.floating.state == floatingVisible && floating != nil
385+ floatingCfg := d.currentFloatingConfig()
331386 overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
332387 if reset || overlayActive {
333388 ac.bars.Reset()
@@ -343,11 +398,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
343398 } else {
344399 frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)
345400 }
401+ contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}
402+ if hasFloating {
403+ frame, damage = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, floatingCfg, layoutSnap, bars.theme, &ac.composed, reset || overlayActive)
404+ }
346405 if overlays.copyActive {
347- frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)
406+ copyPane := overlays.copyPane
407+ if copyPane == nil {
408+ copyPane = p
409+ }
410+ copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingCfg)
411+ frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars)
348412 }
349- if overlays.paletteActive {
350- contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}
413+ // A palette above normal/copy content dims that composed content. When a
414+ // floating pane is present its own backdrop already dims normal pane
415+ // contents; applying the palette backdrop here would also dim the popup.
416+ if overlays.paletteActive && !hasFloating {
351417 (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme)
352418 }
353419 if overlays.copySearchModel != nil {
@@ -371,10 +437,19 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
371437 frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)
372438 }
373439 overlays.Unlock()
440+ cursorPane := p
374441 cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)
375- p.mu.Lock()
376- desiredCursor := desiredCursorOut(p.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive)
377- p.mu.Unlock()
442+ if hasFloating {
443+ cursorPane = floating
444+ }
445+ cursorPane.mu.Lock()
446+ if hasFloating {
447+ desiredGeometry := calculateFloatingGeometry(contentArea, floatingCfg)
448+ geometry := floating.committedFloatingGeometryLocked(desiredGeometry)
449+ cursorContent, cursorVisible = geometry.Inner, geometry.Inner.Width > 0 && geometry.Inner.Height > 0
450+ }
451+ desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive)
452+ cursorPane.mu.Unlock()
378453 ac.sess.mu.Lock()
379454 if ac.sess.v != sess {
380455 ac.sess.mu.Unlock()
@@ -511,13 +586,23 @@ func (c *barCache) Reset() {
511586 }
512587
513588 type composedFrameCache struct {
514- frame renderer.Frame
515- valid bool
516- layoutSnap tabLayoutSnapshot
589+ frame renderer.Frame
590+ valid bool
591+ layoutSnap tabLayoutSnapshot
592+ titleGenerations map[layout.PaneID]uint64
593+ floating *pane
594+ floatingFrame renderer.Frame
595+ floatingGeneration uint64
596+ floatingGeometry floatingGeometry
597+ floatingTitleGeneration uint64
517598 }
518599
519600 func (c *composedFrameCache) invalidate() {
520601 c.valid = false
602+ c.floating = nil
603+ c.floatingGeneration = 0
604+ c.floatingGeometry = floatingGeometry{}
605+ c.floatingTitleGeneration = 0
521606 }
522607
523608 func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
@@ -580,7 +665,14 @@ func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool
580665 }
581666 topBar := frame.Row(0)
582667 drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
583- contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage)
668+ var titleGenerations map[layout.PaneID]uint64
669+ if composed != nil {
670+ if composed.titleGenerations == nil {
671+ composed.titleGenerations = make(map[layout.PaneID]uint64)
672+ }
673+ titleGenerations = composed.titleGenerations
674+ }
675+ contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations)
584676 bottomBar := frame.Row(screenRows + 1)
585677 drawStatusBarState(bottomBar, bars, styles)
586678 if composed != nil {
@@ -618,10 +710,10 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l
618710 }
619711
620712 func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
621- return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false)
713+ return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false, nil)
622714 }
623715
624-func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool) []renderer.Damage {
716+func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleGenerations map[layout.PaneID]uint64) []renderer.Damage {
625717 contentArea := domain.Rect{Width: area.Width, Height: area.Height}
626718 root := tb.tree.Root
627719 placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
@@ -636,6 +728,11 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
636728 }
637729 placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
638730 }
731+ // A valid cache has the same layout, so its title IDs remain valid. Reset
732+ // the existing cache only when rebuilding after layout or frame churn.
733+ if titleGenerations != nil && !cacheValid {
734+ clear(titleGenerations)
735+ }
639736 if ok && !cacheValid {
640737 drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
641738 }
@@ -651,13 +748,16 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
651748 focused := tb.tree.Focus == pl.ID
652749 pl = offsetPlacement(pl, area.X, area.Y)
653750 if pl.TitleBar.Height > 0 {
654- drawPaneTitleBar(frame, pl, p, focused, theme, "")
655- if cacheValid {
751+ generation := drawPaneTitleBar(frame, pl, p, focused, theme)
752+ if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
656753 titleDamage := pl.TitleBar
657754 titleDamage.X -= area.X
658755 titleDamage.Y -= area.Y
659756 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
660757 }
758+ if titleGenerations != nil {
759+ titleGenerations[pl.ID] = generation
760+ }
661761 }
662762 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
663763 if consumeDamage {
@@ -721,7 +821,7 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo
721821 }
722822 }
723823
724-func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme, fallback string) {
824+func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 {
725825 styles := newThemeStyles(theme)
726826 style := styles.border
727827 if focused {
@@ -733,15 +833,11 @@ func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focuse
733833 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
734834 }
735835 p.mu.Lock()
736- title := p.title
836+ title := p.displayTitleLocked()
837+ generation := p.title.generation
737838 p.mu.Unlock()
738- if title == "" {
739- title = fallback
740- }
741- if title == "" {
742- title = string(p.id)
743- }
744839 ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
840+ return generation
745841 }
746842
747843 func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {
M internal/usecase/daemon/render_test.go +113 -5
@@ -631,6 +631,23 @@ func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) {
631631 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
632632 }
633633
634+func TestComposeTabFrameCachedTitleBarsDoNotAllocate(t *testing.T) {
635+ win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
636+ p := win.focusedPane()
637+ p.title.processName = "shell"
638+ win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id)}, Expanded: p.id}
639+ layoutSnap := solveTabLayoutLocked(win)
640+ frame := renderer.NewFrame(20, 5)
641+ titleGenerations := map[layout.PaneID]uint64{p.id: p.title.generation}
642+ p.screen.ClearDamage()
643+
644+ composeTabFrameIntoWithLayoutOptions(win, frame, domain.Rect{Width: 20, Height: 5}, themeui.Theme{}, layoutSnap, true, false, titleGenerations)
645+ allocs := testing.AllocsPerRun(100, func() {
646+ composeTabFrameIntoWithLayoutOptions(win, frame, domain.Rect{Width: 20, Height: 5}, themeui.Theme{}, layoutSnap, true, false, titleGenerations)
647+ })
648+ require.Zero(t, allocs, "valid cached title-bar renders must not allocate")
649+}
650+
634651 func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) {
635652 win := newTab(nil, domain.Size{Cols: 81, Rows: 24})
636653 left := win.focusedPane()
@@ -661,13 +678,24 @@ func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) {
661678 }
662679 }
663680
681+func TestComposeTabFrameStackUsesShellFallback(t *testing.T) {
682+ win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
683+ p := win.focusedPane()
684+ p.title.displayFallback = "fish"
685+ win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id)}, Expanded: p.id}
686+
687+ frame, _ := composeTabFrame(win, domain.Rect{Width: 20, Height: 5}, themeui.Theme{})
688+
689+ require.Equal(t, "fish", rowText(frame.Row(0))[:4])
690+}
691+
664692 func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
665693 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
666694 p1 := win.focusedPane()
667- p1.title = "one"
695+ p1.title.processName = "one"
668696 p1.screen.ClearDamage()
669697 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
670- p2.title = "two"
698+ p2.title.processName = "two"
671699 p2.screen.Write([]byte("T"))
672700 p2.screen.ClearDamage()
673701
@@ -688,9 +716,9 @@ func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
688716 func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
689717 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
690718 p1 := win.focusedPane()
691- p1.title = "one"
719+ p1.title.processName = "one"
692720 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
693- p2.title = "two"
721+ p2.title.processName = "two"
694722 p2.screen.Write([]byte("T"))
695723 win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
696724 win.tree.Focus = p2.id
@@ -702,12 +730,16 @@ func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
702730 p1.screen.ClearDamage()
703731 p2.screen.ClearDamage()
704732
705- p1.title = "renamed"
733+ p1.title.processName = "renamed"
734+ p1.title.generation++
706735 frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
707736
708737 require.Equal(t, "renamed", rowText(frame.Row(1))[:7])
709738 require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset")
710739 require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
740+
741+ _, damage = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
742+ require.NotContains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1}, "unchanged title must not redraw its row")
711743 }
712744
713745 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
@@ -1391,3 +1423,79 @@ func TestHistoryNavBottomBar(t *testing.T) {
13911423 require.True(t, row[docsCol].Style.Equal(styles.accent))
13921424 })
13931425 }
1426+
1427+func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1428+ base := renderer.NewFrame(20, 8)
1429+ for y := range base.Height {
1430+ for x := range base.Width {
1431+ base.Set(x, y, renderer.Cell{Rune: '#', Style: renderer.DefaultStyle()})
1432+ }
1433+ }
1434+ p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
1435+ p.screen.Write([]byte("ab\r\ncd"))
1436+ mode := scopy.NewMode(scopy.NewSnapshot(p.scrollback, p.screen.Frame))
1437+ target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
1438+
1439+ frame, damage := composeCopyClientFrame(mode, p, target, base, barState{})
1440+
1441+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1442+ require.Equal(t, "##ab ", rowText(frame.Row(3)))
1443+ require.Equal(t, "##cd ", rowText(frame.Row(4)))
1444+ require.Equal(t, strings.Repeat("#", 20), rowText(frame.Row(2)))
1445+ require.Equal(t, strings.Repeat("#", 20), rowText(frame.Row(5)))
1446+ require.Equal(t, strings.Repeat("#", 20), rowText(frame.Row(6)))
1447+ status := rowText(frame.Row(7))
1448+ require.Contains(t, status, "[SCROLL]")
1449+ require.NotContains(t, status, "#")
1450+ require.Equal(t, renderer.BlankCell(), frame.At(19, 7), "status filler must be blank cells, not zero cells")
1451+}
1452+
1453+func TestCopyTargetRectLocked(t *testing.T) {
1454+ tb := newTab(nil, domain.Size{Cols: 80, Rows: 23})
1455+ cfg := domain.Defaults().Floating
1456+ contentArea := domain.Rect{Y: 1, Width: 80, Height: 23}
1457+ fp := newPane("floating", nil, domain.Size{Cols: 10, Rows: 4})
1458+ stray := newPane("stray", nil, domain.Size{Cols: 10, Rows: 4})
1459+ tb.mu.Lock()
1460+ defer tb.mu.Unlock()
1461+ layoutSnap := solveTabLayoutLocked(tb)
1462+ main := tb.focusedPane()
1463+
1464+ cases := []struct {
1465+ name string
1466+ pane *pane
1467+ floating *pane
1468+ hasFloating bool
1469+ want domain.Rect
1470+ }{
1471+ {name: "floating source targets popup inner", pane: fp, floating: fp, hasFloating: true, want: calculateFloatingGeometry(contentArea, cfg).Inner},
1472+ {name: "normal source targets solved placement", pane: main, want: domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}},
1473+ {name: "unplaced source falls back to pane frame", pane: stray, want: domain.Rect{X: 0, Y: 1, Width: 10, Height: 4}},
1474+ }
1475+ for _, tc := range cases {
1476+ t.Run(tc.name, func(t *testing.T) {
1477+ require.Equal(t, tc.want, copyTargetRectLocked(layoutSnap, contentArea, tc.pane, tc.floating, tc.hasFloating, cfg))
1478+ })
1479+ }
1480+}
1481+
1482+func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
1483+ normal, releaseNormal := newBlockingPTY(t)
1484+ floatingPTY, releaseFloating := newBlockingPTY(t)
1485+ defer releaseNormal()
1486+ defer releaseFloating()
1487+ d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
1488+ fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
1489+ fp.scrollback.Append(testRow("flt-old"))
1490+ fp.screen.Write([]byte("flt-live"))
1491+ installTestFloating(sess.activeTab(), fp, true)
1492+
1493+ d.enterCopyMode(sess, ac)
1494+ data := mustOutputData(t, sends)
1495+ require.Contains(t, string(data), "[SCROLL]")
1496+ require.Contains(t, string(data), "\x1b[?25l")
1497+
1498+ d.handleInput(sess, ac, []byte("g"))
1499+ data = mustOutputData(t, sends)
1500+ require.Contains(t, string(data), "lt-old", "copy viewport of the captured floating pane must compose above the popup")
1501+}
M internal/usecase/daemon/session.go +22 -2
@@ -55,6 +55,7 @@ type session struct {
5555 cancel context.CancelFunc
5656
5757 mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state
58+ themeMu sync.Mutex
5859 tabs []*tab
5960 active int
6061 client *attachedClient
@@ -74,7 +75,7 @@ type session struct {
7475
7576 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
7677 type tab struct {
77- mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership
78+ mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, previewClient, and pane map membership
7879
7980 stableID string
8081 name string
@@ -84,6 +85,8 @@ type tab struct {
8485 size domain.Size
8586 ctx context.Context
8687 cancel context.CancelFunc
88+ // floating is independent from the normal layout tree and pane map.
89+ floating floatingSlot
8790
8891 // previewClient tracks the one client currently previewing this tab in the picker.
8992 // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
@@ -330,6 +333,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
330333 d.log.Info("tab created", "session", name, "tab", tabIndex)
331334 d.startTabGoroutines(sess, tb)
332335 d.mu.Unlock()
336+ d.activateTab(sess, tb)
333337 markSnapshotDirty(sess)
334338 return nil
335339 }
@@ -371,7 +375,13 @@ func (tb *tab) panesSnapshot() []*pane {
371375 func (tb *tab) closeAllPanes() {
372376 tb.mu.Lock()
373377 panes := tb.panesSnapshot()
378+ // Invalidate before cancellation so a concurrently unblocked floating
379+ // reader cannot reap a subsequently reused slot.
380+ floating := tb.takeFloatingLocked()
374381 tb.mu.Unlock()
382+ if floating != nil {
383+ panes = append(panes, floating)
384+ }
375385 for _, p := range panes {
376386 if p.cancel != nil {
377387 p.cancel()
@@ -401,7 +411,10 @@ func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
401411
402412 func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
403413 if p != nil {
404- d.log.Info("pane created", "session", sess.name, "pane", p.id)
414+ sess.mu.Lock()
415+ name := sess.name
416+ sess.mu.Unlock()
417+ d.log.Info("pane created", "session", name, "pane", p.id)
405418 }
406419 d.sessWg.Add(2)
407420 go d.ptyReader(sess, tb, p)
@@ -599,12 +612,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
599612 return
600613 }
601614 ringing := tb.attention
615+ wasActive := idx == sess.active
602616 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
603617 if sess.active >= len(sess.tabs) {
604618 sess.active = len(sess.tabs) - 1
605619 } else if idx < sess.active {
606620 sess.active--
607621 }
622+ destination := sess.tabs[sess.active]
608623 ac := sess.client
609624 name := sess.name
610625 record := sess.persistRecordLocked(time.Now().UnixNano())
@@ -623,7 +638,11 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
623638 if tb.cancel != nil {
624639 tb.cancel()
625640 }
641+ d.teardownFloating(tb, ac)
626642 tb.closeAllPanes()
643+ if wasActive {
644+ d.activateTab(sess, destination)
645+ }
627646 if repaint && ac != nil {
628647 d.paint(sess, ac, true)
629648 }
@@ -723,6 +742,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
723742 sess.mu.Unlock()
724743 for _, tb := range tabs {
725744 d.clearDestroyedTabPreview(tb)
745+ d.teardownFloating(tb, ac)
726746 tb.closeAllPanes()
727747 }
728748 for _, path := range clipFiles {
M internal/usecase/daemon/session_test.go +74 -7
@@ -33,7 +33,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
3333 defer releaseSecond()
3434 f := portsmocks.NewMockPTYFactory(t)
3535 var dirs []string
36- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
36+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
37+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
3738 func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
3839 dirs = append(dirs, dir)
3940 if len(dirs) == 1 {
@@ -42,6 +43,10 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
4243 return second, nil
4344 },
4445 ).Twice()
46+ floating := newQuietPTY()
47+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
48+ return got != normalSize && got.Valid()
49+ })).Return(floating, nil).Once()
4550
4651 d := newTestDaemon(t, f, stubClock{})
4752 tr := portsmocks.NewMockTransport(t)
@@ -62,6 +67,16 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
6267
6368 require.NoError(t, d.createTab(sess, sz))
6469 require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs)
70+ requireFloatingInitialized(t, sess.activeTab())
71+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
72+ releaseFirst()
73+ releaseSecond()
74+ d.sessWg.Wait()
75+ select {
76+ case <-floating.done:
77+ default:
78+ t.Fatal("floating prewarm PTY was not closed")
79+ }
6580 }
6681
6782 func TestHandshakeEphemeralHappy(t *testing.T) {
@@ -197,8 +212,13 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
197212 p2.EXPECT().Pid().Return(4242).Maybe()
198213
199214 f := portsmocks.NewMockPTYFactory(t)
200- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p1, nil).Once()
201- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
215+ normalSize := domain.Size{Cols: 80, Rows: 22}
216+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()
217+ floating := newQuietPTY()
218+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
219+ return got != normalSize && got.Valid()
220+ })).Return(floating, nil).Once()
221+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
202222 func(string, []string, []string, string, domain.Size) (ports.PTY, error) {
203223 close(opened)
204224 <-releaseOpen
@@ -231,6 +251,11 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
231251 releaseConn()
232252 hg.Wait()
233253 d.sessWg.Wait()
254+ select {
255+ case <-floating.done:
256+ default:
257+ t.Fatal("floating prewarm PTY was not closed")
258+ }
234259 }
235260
236261 // --- ephemeral numbering ----------------------------------------------------
@@ -885,6 +910,29 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
885910 }
886911 }
887912
913+func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) {
914+ d, sess, ac, _, releases := newManualTabSession(t, 2)
915+ sess.mu.Lock()
916+ sess.client = ac
917+ sess.mu.Unlock()
918+ d.ptys = newBlockingOpenFactory(t, d)
919+ defer releases[0]()
920+ defer releases[1]()
921+ sess.mu.Lock()
922+ first, closing := sess.tabs[0], sess.tabs[1]
923+ sess.active = 1
924+ sess.mu.Unlock()
925+ first.mu.Lock()
926+ stale := first.takeFloatingLocked()
927+ first.mu.Unlock()
928+ closeFloatingPane(stale)
929+
930+ d.closeTab(sess, closing, false)
931+
932+ require.Same(t, first, sess.activeTab())
933+ requireFloatingInitialized(t, first)
934+}
935+
888936 func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
889937 sz := domain.Size{Cols: 80, Rows: 24}
890938 p, release := newBlockingPTY(t)
@@ -1194,7 +1242,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
11941242
11951243 var opens [][]string
11961244 f := portsmocks.NewMockPTYFactory(t)
1197- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1245+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1246+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
11981247 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
11991248 opens = append(opens, append([]string(nil), env...))
12001249 if len(opens) == 1 {
@@ -1203,6 +1252,10 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12031252 return p2, nil
12041253 },
12051254 ).Twice()
1255+ floating := newQuietPTY()
1256+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1257+ return got != normalSize && got.Valid()
1258+ })).Return(floating, nil).Once()
12061259
12071260 d := newTestDaemon(t, f, stubClock{})
12081261 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}
@@ -1234,7 +1287,8 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12341287
12351288 var opens [][]string
12361289 f := portsmocks.NewMockPTYFactory(t)
1237- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1290+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1291+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
12381292 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
12391293 opens = append(opens, append([]string(nil), env...))
12401294 if len(opens) == 1 {
@@ -1243,6 +1297,10 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12431297 return p2, nil
12441298 },
12451299 ).Twice()
1300+ floating := newQuietPTY()
1301+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1302+ return got != normalSize && got.Valid()
1303+ })).Return(floating, nil).Once()
12461304
12471305 d := newTestDaemon(t, f, stubClock{})
12481306 tr1 := portsmocks.NewMockTransport(t)
@@ -1276,7 +1334,8 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
12761334 defer release2()
12771335 var opens [][]string
12781336 f := portsmocks.NewMockPTYFactory(t)
1279- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1337+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1338+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
12801339 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
12811340 opens = append(opens, append([]string(nil), env...))
12821341 if len(opens) == 1 {
@@ -1285,6 +1344,10 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
12851344 return p2, nil
12861345 },
12871346 ).Twice()
1347+ floating := newQuietPTY()
1348+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1349+ return got != normalSize && got.Valid()
1350+ })).Return(floating, nil).Once()
12881351 d := newTestDaemon(t, f, stubClock{})
12891352 tr := portsmocks.NewMockTransport(t)
12901353 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
@@ -1295,10 +1358,14 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
12951358 got := ac.sess.Get()
12961359 require.NotNil(t, got)
12971360 got.mu.Lock()
1298- defer got.mu.Unlock()
12991361 require.True(t, got.terminal.TrueColor)
1362+ got.mu.Unlock()
13001363 require.Len(t, opens, 2)
13011364 require.Contains(t, opens[1], "TERM=xterm-direct")
13021365 require.Contains(t, opens[1], "COLORTERM=truecolor")
13031366 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1367+ _ = d.killSession(got, ports.ReasonSessionKilled, false)
1368+ release1()
1369+ release2()
1370+ d.sessWg.Wait()
13041371 }
M internal/usecase/daemon/snapshot_restore_test.go +31 -0
@@ -105,6 +105,37 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
105105 require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)
106106 }
107107
108+func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) {
109+ store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
110+ Name: "work", Active: 0, Tabs: []snapcodec.Tab{
111+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/one"}}},
112+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/two"}}},
113+ },
114+ })}}}
115+ factory := &restorePTYFactory{}
116+ d := newTestDaemon(t, factory, stubClock{})
117+ d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "btop", Width: 80, Height: 80}})
118+ WithSnapshotStore(store)(d)
119+ d.restoreSnapshots(context.Background())
120+ // Only the persisted normal panes may be restored. Floating runtimes are
121+ // deliberately excluded and must wait for a Phase-5 activation path.
122+ require.Len(t, factory.opens, 2)
123+ d.mu.Lock()
124+ restored := d.findByNameLocked("work")
125+ d.mu.Unlock()
126+ require.NotNil(t, restored)
127+ restored.mu.Lock()
128+ tabs := append([]*tab(nil), restored.tabs...)
129+ restored.mu.Unlock()
130+ require.Len(t, tabs, 2)
131+ for _, tb := range tabs {
132+ tb.mu.Lock()
133+ require.Equal(t, floatingUninitialized, tb.floating.state)
134+ require.Nil(t, tb.floating.pane)
135+ tb.mu.Unlock()
136+ }
137+}
138+
108139 func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {
109140 t.Helper()
110141 return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{
M internal/usecase/daemon/snapshot_test.go +24 -0
@@ -63,6 +63,30 @@ func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) {
6363 }
6464 }
6565
66+func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) {
67+ store := portsmocks.NewMockSnapshotStore(t)
68+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
69+ WithSnapshotStore(store)(d)
70+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
71+ tb := sess.tabs[0]
72+ floatingPTY, _ := newBlockingPTY(t)
73+ floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})
74+ tb.mu.Lock()
75+ generation := tb.beginFloatingWarmLocked(false)
76+ require.True(t, tb.installFloatingLocked(floating, generation))
77+ tb.mu.Unlock()
78+
79+ store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
80+ snap, err := snapcodec.Unmarshal(data)
81+ require.NoError(t, err)
82+ require.Len(t, snap.Tabs, 1)
83+ require.Len(t, snap.Tabs[0].Panes, 1, "floating runtime must not be persisted")
84+ require.Equal(t, layout.PaneID("pane-1"), snap.Tabs[0].Panes[0].ID)
85+ return nil
86+ }).Once()
87+ require.True(t, d.captureSession(sess))
88+}
89+
6690 func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {
6791 store := portsmocks.NewMockSnapshotStore(t)
6892 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
M internal/usecase/daemon/status_test.go +46 -11
@@ -155,6 +155,31 @@ func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) {
155155 assertSessionColorScheme(t, sess, true)
156156 }
157157
158+func TestApplyThemePropagatesToFloatingPane(t *testing.T) {
159+ p, releasePTY := newBlockingPTY(t)
160+ defer releasePTY()
161+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
162+ tb := sess.activeTab()
163+ floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5})
164+ installTestFloating(tb, floating, true)
165+ clientTheme := ports.Theme{
166+ HasForeground: true,
167+ Foreground: renderer.RGB{R: 1, G: 2, B: 3},
168+ HasBackground: true,
169+ Background: renderer.RGB{R: 4, G: 5, B: 6},
170+ SchemeKnown: true,
171+ Light: true,
172+ }
173+
174+ d.applyTheme(sess, ac, clientTheme)
175+ assertPaneDefaultColors(t, floating, clientTheme.Foreground, clientTheme.Background)
176+ assertPaneColorScheme(t, floating, true)
177+
178+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark})
179+ assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
180+ assertPaneColorScheme(t, floating, false)
181+}
182+
158183 func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) {
159184 tests := []struct {
160185 name string
@@ -297,26 +322,36 @@ func TestApplyThemeAutoUnknownDoesNotClobberPaneColorScheme(t *testing.T) {
297322 func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB) {
298323 t.Helper()
299324 for _, tb := range sess.tabs {
300- var got []byte
301- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
302- tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
303- require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got))
304- require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))
325+ assertPaneDefaultColors(t, tb.focusedPane(), fg, bg)
305326 }
306327 }
307328
329+func assertPaneDefaultColors(t *testing.T, p *pane, fg, bg renderer.RGB) {
330+ t.Helper()
331+ var got []byte
332+ p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
333+ p.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
334+ require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got))
335+ require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got))
336+}
337+
308338 func assertSessionColorScheme(t *testing.T, sess *session, light bool) {
339+ t.Helper()
340+ for _, tb := range sess.tabs {
341+ assertPaneColorScheme(t, tb.focusedPane(), light)
342+ }
343+}
344+
345+func assertPaneColorScheme(t *testing.T, p *pane, light bool) {
309346 t.Helper()
310347 want := "\x1b[?997;1n"
311348 if light {
312349 want = "\x1b[?997;2n"
313350 }
314- for _, tb := range sess.tabs {
315- var got []byte
316- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
317- tb.focusedPane().screen.Write([]byte("\x1b[?996n"))
318- require.Equal(t, want, string(got))
319- }
351+ var got []byte
352+ p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
353+ p.screen.Write([]byte("\x1b[?996n"))
354+ require.Equal(t, want, string(got))
320355 }
321356
322357 func assertSessionColorSchemeUnknown(t *testing.T, sess *session) {
M internal/usecase/daemon/theme_resolve.go +8 -0
@@ -7,7 +7,12 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
77 ac.sendMu.Lock()
88 defer ac.sendMu.Unlock()
99 }
10+ sess.themeMu.Lock()
11+ defer sess.themeMu.Unlock()
12+ return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
13+}
1014
15+func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
1116 sess.mu.Lock()
1217 if ac != nil {
1318 if sess.client != ac {
@@ -28,6 +33,9 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
2833 for _, tb := range tabs {
2934 tb.mu.Lock()
3035 panes := tb.panesSnapshot()
36+ if floating := tb.floating.pane; floating != nil {
37+ panes = append(panes, floating)
38+ }
3139 tb.mu.Unlock()
3240 for _, p := range panes {
3341 p.mu.Lock()
A internal/usecase/daemon/title_cache_regression_test.go +37 -0
@@ -0,0 +1,37 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+)
12+
13+func TestComposeClientFrameCachePrunesTitleGenerationsAfterLayoutChurn(t *testing.T) {
14+ win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
15+ first := win.focusedPane()
16+ var bars barCache
17+ var composed composedFrameCache
18+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
19+
20+ for i := 0; i < 8; i++ {
21+ id := layout.PaneID(fmt.Sprintf("pane-%d", i+2))
22+ replacement := newPane(id, nil, domain.Size{Cols: 20, Rows: 3})
23+ win.panes = map[layout.PaneID]*pane{first.id: first, id: replacement}
24+ win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{
25+ layout.NewLeaf(first.id),
26+ layout.NewLeaf(id),
27+ }, Expanded: id}
28+ win.tree.Focus = id
29+
30+ composeClientFrameWithLayoutCached(state, win, i == 0, solveTabLayoutLocked(win), &bars, &composed)
31+
32+ require.Equal(t, map[layout.PaneID]uint64{
33+ first.id: composed.titleGenerations[first.id],
34+ id: composed.titleGenerations[id],
35+ }, composed.titleGenerations)
36+ }
37+}
M internal/usecase/keys/bindings.go +1 -0
@@ -17,6 +17,7 @@ func DefaultBindings() *Bindings {
1717 altRunes := map[rune]Action{
1818 ' ': ActionOpenPalette,
1919 'a': ActionJumpAttention,
20+ 'f': ActionToggleFloatingPane,
2021 'h': ActionFocusPaneLeft,
2122 'j': ActionFocusPaneDown,
2223 'k': ActionFocusPaneUp,
M internal/usecase/keys/keyspec.go +16 -15
@@ -49,21 +49,22 @@ func ParseKeySpec(spec string) (KeySpec, error) {
4949 }
5050
5151 var actionNames = map[Action]string{
52- ActionOpenPalette: "open-palette",
53- ActionJumpAttention: "jump-attention",
54- ActionFocusPaneLeft: "focus-pane-left",
55- ActionFocusPaneRight: "focus-pane-right",
56- ActionFocusPaneUp: "focus-pane-up",
57- ActionFocusPaneDown: "focus-pane-down",
58- ActionSwitchTab1: "switch-tab-1",
59- ActionSwitchTab2: "switch-tab-2",
60- ActionSwitchTab3: "switch-tab-3",
61- ActionSwitchTab4: "switch-tab-4",
62- ActionSwitchTab5: "switch-tab-5",
63- ActionSwitchTab6: "switch-tab-6",
64- ActionSwitchTab7: "switch-tab-7",
65- ActionSwitchTab8: "switch-tab-8",
66- ActionSwitchTab9: "switch-tab-9",
52+ ActionOpenPalette: "open-palette",
53+ ActionToggleFloatingPane: "toggle-floating-pane",
54+ ActionJumpAttention: "jump-attention",
55+ ActionFocusPaneLeft: "focus-pane-left",
56+ ActionFocusPaneRight: "focus-pane-right",
57+ ActionFocusPaneUp: "focus-pane-up",
58+ ActionFocusPaneDown: "focus-pane-down",
59+ ActionSwitchTab1: "switch-tab-1",
60+ ActionSwitchTab2: "switch-tab-2",
61+ ActionSwitchTab3: "switch-tab-3",
62+ ActionSwitchTab4: "switch-tab-4",
63+ ActionSwitchTab5: "switch-tab-5",
64+ ActionSwitchTab6: "switch-tab-6",
65+ ActionSwitchTab7: "switch-tab-7",
66+ ActionSwitchTab8: "switch-tab-8",
67+ ActionSwitchTab9: "switch-tab-9",
6768 }
6869
6970 var actionsByName = func() map[string]Action {
M internal/usecase/keys/keyspec_test.go +7 -3
@@ -64,6 +64,7 @@ func TestActionNamesAreCanonical(t *testing.T) {
6464 name string
6565 }{
6666 {ActionOpenPalette, "open-palette"},
67+ {ActionToggleFloatingPane, "toggle-floating-pane"},
6768 {ActionJumpAttention, "jump-attention"},
6869 {ActionFocusPaneLeft, "focus-pane-left"},
6970 {ActionFocusPaneRight, "focus-pane-right"},
@@ -96,14 +97,17 @@ func TestActionNamesAreCanonical(t *testing.T) {
9697
9798 func TestBuildBindingsAppliesOverrides(t *testing.T) {
9899 bindings, warnings := BuildBindings(map[string]string{
99- "open-palette": "alt+o",
100- "focus-pane-left": "alt+left",
101- "switch-tab-2": "alt+2",
100+ "open-palette": "alt+o",
101+ "toggle-floating-pane": "alt+g",
102+ "focus-pane-left": "alt+left",
103+ "switch-tab-2": "alt+2",
102104 })
103105 require.Empty(t, warnings)
104106
105107 assertAltRuneAction(t, bindings, 'o', ActionOpenPalette)
106108 assertAltRuneUnbound(t, bindings, ' ')
109+ assertAltRuneAction(t, bindings, 'g', ActionToggleFloatingPane)
110+ assertAltRuneUnbound(t, bindings, 'f')
107111 assertAltArrowAction(t, bindings, 'D', ActionFocusPaneLeft)
108112 assertAltRuneUnbound(t, bindings, 'h')
109113 assertAltRuneAction(t, bindings, '2', ActionSwitchTab2)
M internal/usecase/keys/router.go +1 -0
@@ -37,6 +37,7 @@ const (
3737 ActionFocusPaneRight
3838 ActionFocusPaneUp
3939 ActionFocusPaneDown
40+ ActionToggleFloatingPane
4041 )
4142
4243 // Handler receives router outputs. Forward is called only for bytes that should
M internal/usecase/keys/router_test.go +8 -0
@@ -53,6 +53,7 @@ func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) {
5353 want Action
5454 }{
5555 {name: "palette", in: []byte{ESC, ' '}, want: ActionOpenPalette},
56+ {name: "floating pane", in: []byte{ESC, 'f'}, want: ActionToggleFloatingPane},
5657 {name: "jump attention", in: []byte{ESC, 'a'}, want: ActionJumpAttention},
5758 {name: "focus left", in: []byte{ESC, 'h'}, want: ActionFocusPaneLeft},
5859 {name: "focus down", in: []byte{ESC, 'j'}, want: ActionFocusPaneDown},
@@ -237,6 +238,13 @@ func switchTabActions() []Action {
237238 }
238239 }
239240
241+func TestSwitchTabActionsRemainContiguous(t *testing.T) {
242+ for i, action := range switchTabActions() {
243+ require.Equal(t, ActionSwitchTab1+Action(i), action)
244+ }
245+ require.False(t, ActionToggleFloatingPane >= ActionSwitchTab1 && ActionToggleFloatingPane <= ActionSwitchTab9)
246+}
247+
240248 func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {
241249 clk := &fakeClock{}
242250 h := &captureHandler{}
M pkg/vt/screen.go +11 -3
@@ -84,6 +84,7 @@ type Screen struct {
8484 defaultFG renderer.RGB
8585 defaultBG renderer.RGB
8686 defaultColorsKnown bool
87+ terminalTitle string
8788
8889 damage []renderer.Damage
8990 escapeBuf []byte
@@ -218,6 +219,9 @@ func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyle
218219 func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR }
219220 func (s *Screen) AltScreenActive() bool { return s.alternate != nil }
220221
222+// TerminalTitle returns the latest title set by OSC 0 or OSC 2.
223+func (s *Screen) TerminalTitle() string { return s.terminalTitle }
224+
221225 func (s *Screen) Write(data []byte) {
222226 if len(s.escapeBuf) > 0 {
223227 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
@@ -735,15 +739,19 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte {
735739 }
736740
737741 // handleOSC inspects a complete OSC payload (between "ESC ]" and its
738-// terminator). Notification sequences (OSC 9, OSC 777 "notify") and clipboard
739-// set requests (OSC 52) are acted on; titles and every other OSC are still
740-// discarded.
742+// terminator). Terminal titles, notification sequences (OSC 9, OSC 777
743+// "notify"), and clipboard set requests (OSC 52) are acted on; every other OSC
744+// is discarded.
741745 func (s *Screen) handleOSC(payload []byte) {
742746 if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
743747 s.handleOSC52(string(payload[len("52;"):]))
744748 return
745749 }
746750 p := string(payload)
751+ if selector, title, ok := strings.Cut(p, ";"); ok && (selector == "0" || selector == "2") {
752+ s.terminalTitle = title
753+ return
754+ }
747755 if p == "9;4" || strings.HasPrefix(p, "9;4;") {
748756 s.handleProgress(p[len("9;4"):])
749757 return
M pkg/vt/screen_test.go +64 -0
@@ -185,6 +185,70 @@ func TestBELWithoutCallbackIsDiscarded(t *testing.T) {
185185 require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
186186 }
187187
188+func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) {
189+ tests := []struct {
190+ name string
191+ seq string
192+ want string
193+ }{
194+ {name: "OSC 0 BEL terminated", seq: "\x1b]0;zero title\x07", want: "zero title"},
195+ {name: "OSC 0 ST terminated", seq: "\x1b]0;zero title\x1b\\", want: "zero title"},
196+ {name: "OSC 2 BEL terminated", seq: "\x1b]2;two title\x07", want: "two title"},
197+ {name: "OSC 2 ST terminated", seq: "\x1b]2;two title\x1b\\", want: "two title"},
198+ }
199+
200+ for _, tt := range tests {
201+ t.Run(tt.name, func(t *testing.T) {
202+ s := NewScreen(10, 2)
203+ bells := 0
204+ s.OnBell = func() { bells++ }
205+ s.ClearDamage()
206+
207+ s.Write([]byte(tt.seq))
208+
209+ require.Equal(t, tt.want, s.TerminalTitle())
210+ require.Equal(t, 0, bells)
211+ require.Empty(t, s.Damage())
212+ require.Equal(t, strings.Repeat(" ", s.Frame.Width), rowString(s.Frame.Row(0)))
213+ })
214+ }
215+}
216+
217+func TestTerminalTitleHandlesSplitWritesReplacementAndClear(t *testing.T) {
218+ s := NewScreen(10, 2)
219+
220+ s.Write([]byte("\x1b]0;split"))
221+ s.Write([]byte(" title\x1b"))
222+ require.Empty(t, s.TerminalTitle())
223+ s.Write([]byte("\\"))
224+ require.Equal(t, "split title", s.TerminalTitle())
225+
226+ s.Write([]byte("\x1b]2;replacement\x07"))
227+ require.Equal(t, "replacement", s.TerminalTitle())
228+
229+ s.Write([]byte("\x1b]0;\x07"))
230+ require.Empty(t, s.TerminalTitle())
231+}
232+
233+func TestTerminalTitleIgnoresUnrelatedAndOversizedOSC(t *testing.T) {
234+ s := NewScreen(10, 2)
235+ s.Write([]byte("\x1b]0;retained\x07"))
236+
237+ for _, seq := range []string{
238+ "\x1b]1;icon title\x07",
239+ "\x1b]20;not selector two\x1b\\",
240+ "\x1b]777;other;x;y\x07",
241+ } {
242+ s.Write([]byte(seq))
243+ require.Equal(t, "retained", s.TerminalTitle())
244+ }
245+
246+ payload := strings.Repeat("x", maxEscapeBufferLen+1)
247+ s.Write([]byte("\x1b]2;" + payload))
248+ require.Equal(t, "retained", s.TerminalTitle())
249+ require.Empty(t, s.escapeBuf)
250+}
251+
188252 func TestOnNotifyOSC9(t *testing.T) {
189253 s := NewScreen(10, 2)
190254 var gotTitle, gotBody string

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