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
49 # Theme: auto follows the client; dark/light force built-in palettes.49 # Theme: auto follows the client; dark/light force built-in palettes.
50 theme = auto50 theme = auto
51 51
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+
52 # Rebindable actions. Leave a line out to keep its built-in binding.57 # Rebindable actions. Leave a line out to keep its built-in binding.
53 open-palette = alt+space58 open-palette = alt+space
59+toggle-floating-pane = alt+f
54 jump-attention = alt+a60 jump-attention = alt+a
55 focus-pane-left = alt+h61 focus-pane-left = alt+h
56 focus-pane-right = alt+l62 focus-pane-right = alt+l
@@ -97,6 +103,7 @@ code.back-session = BSK
97 code.forward-session = FSK103 code.forward-session = FSK
98 code.session-picker = SSP104 code.session-picker = SSP
99 code.visual-mode = VIS105 code.visual-mode = VIS
106+code.toggle-floating-pane = FLT
100 code.rename-session = RNS107 code.rename-session = RNS
101 code.detach = DET108 code.detach = DET
102 ```109 ```
@@ -105,6 +112,8 @@ Configuring an action replaces all of its built-in aliases. In the example above
105 112
106 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.113 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.
107 114
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+
108 ### Bar right anchors117 ### Bar right anchors
109 118
110 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.119 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.
145 | Key | Action |154 | Key | Action |
146 |---|---|155 |---|---|
147 | Alt+Space | open the command palette |156 | Alt+Space | open the command palette |
157+| Alt+f | show or hide the current tab's floating terminal |
148 | Alt+1 .. Alt+9 | switch to tab by number |158 | Alt+1 .. Alt+9 | switch to tab by number |
149 | Alt+h/j/k/l | focus pane left/down/up/right |159 | Alt+h/j/k/l | focus pane left/down/up/right |
150 | Alt+Arrow | focus pane by direction |160 | Alt+Arrow | focus pane by direction |
@@ -172,6 +182,7 @@ Successful commands are promoted near the top the next time the palette opens.
172 | BSK / FSK | move back / forward through recent sessions |182 | BSK / FSK | move back / forward through recent sessions |
173 | SSP | open session picker |183 | SSP | open session picker |
174 | VIS | enter visual mode |184 | VIS | enter visual mode |
185+| FLT | show or hide the current tab's floating terminal |
175 | RNS | rename session |186 | RNS | rename session |
176 | RNT | rename current tab |187 | RNT | rename current tab |
177 | DET | detach |188 | DET | detach |
@@ -193,6 +204,10 @@ Open it with `SSP` in the palette. It lists sessions and tabs, previews the sele
193 204
194 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.205 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.
195 206
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+
196 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*`.211 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*`.
197 212
198 ## Scrollback mode and copy213 ## Scrollback mode and copy
M internal/adapters/config/config.go +45 -1
@@ -18,7 +18,10 @@ import (
18 18
19 const pollInterval = 2 * time.Second19 const pollInterval = 2 * time.Second
20 20
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+)
22 25
23 // Parse reads vev's flat action = value config format. Duplicate action keys26 // Parse reads vev's flat action = value config format. Duplicate action keys
24 // are accepted with a warning; the last value wins while first-seen action order27 // 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) {
27 cfg := domain.Defaults()30 cfg := domain.Defaults()
28 var warnings []domain.Warning31 var warnings []domain.Warning
29 seenBindingKeys := make(map[string]bool)32 seenBindingKeys := make(map[string]bool)
33+ seenFloatingKeys := make(map[string]bool)
30 34
31 scanner := bufio.NewScanner(r)35 scanner := bufio.NewScanner(r)
32 lineNo := 036 lineNo := 0
@@ -86,6 +90,30 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
86 interval = domain.MinBarInterval90 interval = domain.MinBarInterval
87 }91 }
88 cfg.Bar.Interval = interval92 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
89 case strings.HasPrefix(key, "code."):117 case strings.HasPrefix(key, "code."):
90 codeKey := strings.TrimPrefix(key, "code.")118 codeKey := strings.TrimPrefix(key, "code.")
91 if codeKey == "" {119 if codeKey == "" {
@@ -189,6 +217,14 @@ func fileStamp(path string) (stamp, error) {
189 return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil217 return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil
190 }218 }
191 219
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+
192 func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {228 func updateBindingEntry(entries []domain.ConfigEntry, key, value string) {
193 for i := range entries {229 for i := range entries {
194 if entries[i].Key == key {230 if entries[i].Key == key {
@@ -224,6 +260,14 @@ func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) {
224 return out, warnings260 return out, warnings
225 }261 }
226 262
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+
227 func parseBarCommand(value string) (string, bool) {271 func parseBarCommand(value string) (string, bool) {
228 value = strings.TrimSpace(value)272 value = strings.TrimSpace(value)
229 if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {273 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) {
91 {Line: 3, Msg: "missing key"},91 {Line: 3, Msg: "missing key"},
92 },92 },
93 },93 },
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+ },
94 {185 {
95 name: "bar settings custom values",186 name: "bar settings custom values",
96 input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n",187 input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n",
@@ -244,6 +335,9 @@ func TestParse(t *testing.T) {
244 if tt.want.Bar.Interval == 0 {335 if tt.want.Bar.Interval == 0 {
245 tt.want.Bar = domain.Defaults().Bar336 tt.want.Bar = domain.Defaults().Bar
246 }337 }
338+ if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 {
339+ tt.want.Floating = domain.Defaults().Floating
340+ }
247 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {341 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
248 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)342 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
249 }343 }
M internal/domain/config.go +12 -0
@@ -47,6 +47,13 @@ type BarConfig struct {
47 Interval time.Duration47 Interval time.Duration
48 }48 }
49 49
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+
50 // Config is the user-editable vev configuration after parsing. Unknown binding57 // Config is the user-editable vev configuration after parsing. Unknown binding
51 // keys are preserved here (in BindingEntries, in file order) so the usecase58 // keys are preserved here (in BindingEntries, in file order) so the usecase
52 // layer can decide which actions it understands.59 // layer can decide which actions it understands.
@@ -56,6 +63,7 @@ type Config struct {
56 BindingEntries []ConfigEntry63 BindingEntries []ConfigEntry
57 Codes map[string]string64 Codes map[string]string
58 Snapshot SnapshotConfig65 Snapshot SnapshotConfig
66+ Floating FloatingConfig
59 }67 }
60 68
61 // Warning describes a non-fatal config problem. Parsers and reloaders should69 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -79,5 +87,9 @@ func Defaults() Config {
79 Snapshot: SnapshotConfig{87 Snapshot: SnapshotConfig{
80 RestoreProcesses: DefaultSnapshotRestoreProcesses(),88 RestoreProcesses: DefaultSnapshotRestoreProcesses(),
81 },89 },
90+ Floating: FloatingConfig{
91+ Width: 80,
92+ Height: 80,
93+ },
82 }94 }
83 }95 }
M internal/usecase/command/command.go +1 -0
@@ -11,6 +11,7 @@ type Context interface {
11 SplitDown() error11 SplitDown() error
12 StackPane() error12 StackPane() error
13 ToggleStack() error13 ToggleStack() error
14+ ToggleFloatingPane() error
14 ClosePane() error15 ClosePane() error
15 FocusPaneLeft() error16 FocusPaneLeft() error
16 FocusPaneRight() error17 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
1003 return _c1003 return _c
1004 }1004 }
1005 1005
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+
1006 // ToggleStack provides a mock function for the type MockContext1050 // ToggleStack provides a mock function for the type MockContext
1007 func (_mock *MockContext) ToggleStack() error {1051 func (_mock *MockContext) ToggleStack() error {
1008 ret := _mock.Called()1052 ret := _mock.Called()
M internal/usecase/command/registry.go +1 -0
@@ -14,6 +14,7 @@ func Registry() []Command {
14 {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }},14 {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }},
15 {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }},15 {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }},
16 {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }},16 {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() }},
17 {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }},18 {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }},
18 {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() }},19 {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() }},
19 {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() }},20 {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 (
7 7
8 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {8 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
9 commands := Registry()9 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"}
11 11
12 if len(commands) != len(wantCodes) {12 if len(commands) != len(wantCodes) {
13 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))13 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -37,6 +37,16 @@ func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
37 }37 }
38 }38 }
39 39
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+
40 func TestByCodeIsCaseInsensitive(t *testing.T) {50 func TestByCodeIsCaseInsensitive(t *testing.T) {
41 for _, code := range []string{"CNT", "cnt", "CnT"} {51 for _, code := range []string{"CNT", "cnt", "CnT"} {
42 cmd, ok := ByCode(code)52 cmd, ok := ByCode(code)
@@ -67,6 +77,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
67 {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }},77 {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }},
68 {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }},78 {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }},
69 {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }},79 {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() }},
70 {code: "CLP", expect: func(ctx *MockContext) { ctx.EXPECT().ClosePane().Return(nil).Once() }},81 {code: "CLP", expect: func(ctx *MockContext) { ctx.EXPECT().ClosePane().Return(nil).Once() }},
71 {code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }},82 {code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }},
72 {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }},83 {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) {
48 }48 }
49 if idx, ok := oldestAttentionTab(sess); ok {49 if idx, ok := oldestAttentionTab(sess); ok {
50 if sess.switchTab(idx) {50 if sess.switchTab(idx) {
51+ d.activateTab(sess, sess.activeTab())
51 d.paint(sess, ac, true)52 d.paint(sess, ac, true)
52 }53 }
53 return54 return
M internal/usecase/daemon/attention_test.go +5 -0
@@ -440,6 +440,10 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
440 440
441 func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {441 func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
442 d, sess, ac, sends, releases := newManualTabSession(t, 3)442 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)
443 defer releases[0]()447 defer releases[0]()
444 defer releases[1]()448 defer releases[1]()
445 defer releases[2]()449 defer releases[2]()
@@ -454,6 +458,7 @@ func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) {
454 d.handleInput(sess, ac, []byte("\x1ba"))458 d.handleInput(sess, ac, []byte("\x1ba"))
455 459
456 require.Equal(t, 2, activeTabIndex(sess))460 require.Equal(t, 2, activeTabIndex(sess))
461+ requireFloatingInitialized(t, sess.activeTab())
457 _ = mustOutputData(t, sends)462 _ = mustOutputData(t, sends)
458 }463 }
459 464
M internal/usecase/daemon/client.go +5 -0
@@ -443,10 +443,12 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
443 443
444 if clientSize.Valid() && wsz != tabSize(clientSize) {444 if clientSize.Valid() && wsz != tabSize(clientSize) {
445 d.resize(sess, ac, clientSize)445 d.resize(sess, ac, clientSize)
446+ d.activateTab(sess, sess.activeTab())
446 return447 return
447 }448 }
448 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)449 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
449 d.paint(sess, ac, true)450 d.paint(sess, ac, true)
451+ d.activateTab(sess, tb)
450 }452 }
451 453
452 // runConnLoop is the per-connection input router: it pumps client messages454 // 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) {
594 d.applyLayoutLocked(tb)596 d.applyLayoutLocked(tb)
595 tb.mu.Unlock()597 tb.mu.Unlock()
596 }598 }
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())
597 markSnapshotDirty(sess)602 markSnapshotDirty(sess)
598 if ac != nil {603 if ac != nil {
599 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)604 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) {
91 return91 return
92 }92 }
93 tb.mu.Lock()93 tb.mu.Lock()
94- p := tb.focusedPane()94+ p := tb.terminalTargetLocked()
95 tb.mu.Unlock()95 tb.mu.Unlock()
96 if p == nil {96 if p == nil {
97 return97 return
M internal/usecase/daemon/clipboard_test.go +19 -0
@@ -13,11 +13,30 @@ import (
13 "github.com/stretchr/testify/mock"13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15 15
16+ "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
17 portsmocks "github.com/bnema/vev/internal/ports/mocks"18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18 scopy "github.com/bnema/vev/internal/usecase/copy"19 scopy "github.com/bnema/vev/internal/usecase/copy"
19 )20 )
20 21
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+
21 func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {40 func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {
22 writes := make(chan []byte, 1)41 writes := make(chan []byte, 1)
23 p, releasePTY := newBlockingPTYWithWrites(t, writes)42 p, releasePTY := newBlockingPTYWithWrites(t, writes)
M internal/usecase/daemon/config.go +25 -8
@@ -27,6 +27,8 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
27 d.bindings.Store(bindings)27 d.bindings.Store(bindings)
28 d.codeOverrides.Store(&overrides)28 d.codeOverrides.Store(&overrides)
29 d.restoreProcessAllowlist.Store(&allowlist)29 d.restoreProcessAllowlist.Store(&allowlist)
30+ floating := cfg.Floating
31+ d.floatingConfig.Store(&floating)
30 d.themeMode.Store(uint32(cfg.Theme))32 d.themeMode.Store(uint32(cfg.Theme))
31 if d.barScripts != nil {33 if d.barScripts != nil {
32 d.barScripts.mu.Lock()34 d.barScripts.mu.Lock()
@@ -176,6 +178,13 @@ func (d *Daemon) logConfigWarning(w domain.Warning) {
176 d.log.Warn("config warning", "msg", w.Msg)178 d.log.Warn("config warning", "msg", w.Msg)
177 }179 }
178 180
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+
179 func (d *Daemon) codeOverrideSnapshot() map[string]string {188 func (d *Daemon) codeOverrideSnapshot() map[string]string {
180 overrides := d.codeOverrides.Load()189 overrides := d.codeOverrides.Load()
181 if overrides == nil {190 if overrides == nil {
@@ -223,13 +232,21 @@ func (d *Daemon) reapplyThemeAllSessions() {
223 d.mu.Unlock()232 d.mu.Unlock()
224 233
225 for _, sess := range sessions {234 for _, sess := range sessions {
226- sess.mu.Lock()235+ d.reapplyThemeSession(sess)
227- ac := sess.client236+ }
228- sess.mu.Unlock()237+}
229- var clientTheme theme.Theme238+
230- if ac != nil {239+func (d *Daemon) reapplyThemeSession(sess *session) {
231- clientTheme = ac.getClientTheme()240+ sess.mu.Lock()
232- }241+ ac := sess.client
233- d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme), ac == nil)242+ sess.mu.Unlock()
243+ if ac == nil {
244+ d.applyHostTheme(sess, nil, d.effectiveTheme(theme.Theme{}), true)
245+ return
234 }246 }
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)
235 }252 }
M internal/usecase/daemon/config_test.go +18 -0
@@ -49,6 +49,24 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) {
49 require.Equal(t, "NT", cmd.Code)49 require.Equal(t, "NT", cmd.Code)
50 }50 }
51 51
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+
52 func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {70 func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {
53 tests := []struct {71 tests := []struct {
54 name string72 name string
M internal/usecase/daemon/copymode.go +46 -54
@@ -31,7 +31,6 @@ import (
31 "github.com/bnema/vev/internal/ports"31 "github.com/bnema/vev/internal/ports"
32 scopy "github.com/bnema/vev/internal/usecase/copy"32 scopy "github.com/bnema/vev/internal/usecase/copy"
33 "github.com/bnema/vev/internal/usecase/keys"33 "github.com/bnema/vev/internal/usecase/keys"
34- "github.com/bnema/vev/internal/usecase/layout"
35 "github.com/bnema/vev/internal/usecase/mouse"34 "github.com/bnema/vev/internal/usecase/mouse"
36 "github.com/bnema/vev/internal/usecase/ui"35 "github.com/bnema/vev/internal/usecase/ui"
37 "github.com/bnema/vev/internal/usecase/visualsearch"36 "github.com/bnema/vev/internal/usecase/visualsearch"
@@ -40,22 +39,24 @@ import (
40 39
41 var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: ui.AnchorBottom, BottomMargin: 1}40 var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: ui.AnchorBottom, BottomMargin: 1}
42 41
43-func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {42+func copyTargetPane(rt *overlayRuntime) *pane {
44- rt := ac.overlays43+ if rt == nil {
45- tb := sess.activeTab()44+ return nil
46- if tb == nil {
47- return
48 }45 }
46+ rt.copyMu.Lock()
47+ defer rt.copyMu.Unlock()
48+ return rt.copyPane
49+}
49 50
50- tb.mu.Lock()51+func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
51- p := tb.focusedPane()52+ rt := ac.overlays
52- tb.mu.Unlock()53+ p := copyTargetPane(rt)
53 if p == nil {54 if p == nil {
54 return55 return
55 }56 }
56 p.mu.Lock()57 p.mu.Lock()
57 rt.copyMu.Lock()58 rt.copyMu.Lock()
58- if rt.copyMode == nil {59+ if rt.copyMode == nil || rt.copyPane != p {
59 rt.copyMu.Unlock()60 rt.copyMu.Unlock()
60 p.mu.Unlock()61 p.mu.Unlock()
61 return62 return
@@ -86,7 +87,8 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
86 return87 return
87 }88 }
88 tb.mu.Lock()89 tb.mu.Lock()
89- p := tb.focusedPane()90+ p := tb.terminalTargetLocked()
91+ floatingSource := tb.floating.state == floatingVisible && tb.floating.pane == p
90 tb.mu.Unlock()92 tb.mu.Unlock()
91 if p == nil {93 if p == nil {
92 return94 return
@@ -96,12 +98,21 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
96 p.mu.Unlock()98 p.mu.Unlock()
97 rt.copyMu.Lock()99 rt.copyMu.Lock()
98 rt.copyMode = scopy.NewMode(snap)100 rt.copyMode = scopy.NewMode(snap)
101+ rt.copyPane = p
99 rt.copySearch = nil102 rt.copySearch = nil
100 rt.copySearchPending = nil103 rt.copySearchPending = nil
101 rt.copyPressRowValid = false104 rt.copyPressRowValid = false
102 rt.copyDragging = false105 rt.copyDragging = false
103 rt.normalMousePressValid = false106 rt.normalMousePressValid = false
104 rt.copyMu.Unlock()107 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+ }
105 d.paint(sess, ac, true)116 d.paint(sess, ac, true)
106 }117 }
107 118
@@ -110,14 +121,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
110 if ev.Button != mouse.Left {121 if ev.Button != mouse.Left {
111 return122 return
112 }123 }
113- tb := sess.activeTab()124+ p := copyTargetPane(rt)
114- if tb == nil {
115- return
116- }
117-
118- tb.mu.Lock()
119- p := tb.focusedPane()
120- tb.mu.Unlock()
121 if p == nil {125 if p == nil {
122 return126 return
123 }127 }
@@ -133,7 +137,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
133 return137 return
134 }138 }
135 rt.copyMu.Lock()139 rt.copyMu.Lock()
136- if rt.copyMode == nil {140+ if rt.copyMode == nil || rt.copyPane != p {
137 rt.copyMu.Unlock()141 rt.copyMu.Unlock()
138 p.mu.Unlock()142 p.mu.Unlock()
139 return143 return
@@ -175,20 +179,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
175 if sess == nil {179 if sess == nil {
176 return180 return
177 }181 }
178- tb := sess.activeTab()182+ p := copyTargetPane(rt)
179- if tb == nil {
180- return
181- }
182-
183- tb.mu.Lock()
184- p := tb.focusedPane()
185- tb.mu.Unlock()
186 if p == nil {183 if p == nil {
187 return184 return
188 }185 }
189 p.mu.Lock()186 p.mu.Lock()
190 rt.copyMu.Lock()187 rt.copyMu.Lock()
191- if rt.copyMode == nil {188+ if rt.copyMode == nil || rt.copyPane != p {
192 rt.copyPending = nil189 rt.copyPending = nil
193 d.stopCopyPendingTimerLocked(ac)190 d.stopCopyPendingTimerLocked(ac)
194 rt.copyMu.Unlock()191 rt.copyMu.Unlock()
@@ -420,36 +417,31 @@ func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame
420 return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)417 return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
421 }418 }
422 419
423-func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {420+func composeCopyClientFrame(mode *scopy.Mode, p *pane, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
424- styles := newThemeStyles(bars.theme)421+ if mode == nil || p == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
425- p := tb.focusedPane()422+ return frame, nil
426- if p == nil {
427- return renderer.NewFrame(0, 0), nil
428 }423 }
424+ styles := newThemeStyles(bars.theme)
429 p.mu.Lock()425 p.mu.Lock()
430- paneFrame := p.screen.Frame426+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
431- paneWidth, paneRows := paneFrame.Width, paneFrame.Height
432- snap := scopy.NewSnapshot(p.scrollback, paneFrame)
433 p.mu.Unlock()427 p.mu.Unlock()
434 copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)428 copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
435- pl, ok := focusedPlacementLocked(tb)429+ bodyRows := max(copyFrame.Height-1, 0)
436- if !ok {430+ for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
437- pl = layout.Placement{Content: domain.Rect{Width: paneWidth, Height: paneRows}}431+ dstX := max(target.X, 0)
438- }432+ srcX := max(-target.X, 0)
439- width, screenRows := paneWidth, paneRows433+ width := min(target.Width-srcX, copyFrame.Width-srcX)
440- if len(tb.panes) > 1 && tb.size.Valid() {434+ width = min(width, frame.Width-dstX)
441- width, screenRows = tb.size.Cols, tb.size.Rows435+ if width > 0 && target.Y+y >= 0 {
442- }436+ copy(frame.Row(target.Y + y)[dstX:dstX+width], copyFrame.Row(y)[srcX:srcX+width])
443- frame := renderer.NewFrame(width, screenRows+2)437+ }
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))
448 }438 }
449- for y := 0; y < pl.Content.Height && y < copyFrame.Height-1; y++ {439+ statusY := frame.Height - 1
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)])440+ statusRow := frame.Row(statusY)
441+ blank := renderer.BlankCell()
442+ for x := range statusRow {
443+ statusRow[x] = blank
451 }444 }
452- statusY := screenRows + 1445+ copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])
453- copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(width, copyFrame.Width)])
454 return frame, []renderer.Damage{renderer.FullRedraw()}446 return frame, []renderer.Damage{renderer.FullRedraw()}
455 }447 }
M internal/usecase/daemon/copymode_test.go +188 -9
@@ -17,6 +17,7 @@ import (
17 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 scopy "github.com/bnema/vev/internal/usecase/copy"19 scopy "github.com/bnema/vev/internal/usecase/copy"
20+ "github.com/bnema/vev/internal/usecase/layout"
20 "github.com/bnema/vev/pkg/renderer"21 "github.com/bnema/vev/pkg/renderer"
21 "github.com/bnema/vev/pkg/vt"22 "github.com/bnema/vev/pkg/vt"
22 )23 )
@@ -107,6 +108,9 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
107 mode := scopy.NewMode(snap)108 mode := scopy.NewMode(snap)
108 bars := barState{status: sess.statusSegments()}109 bars := barState{status: sess.statusSegments()}
109 110
111+ base := renderer.NewFrame(80, 25)
112+ target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}
113+
110 var wg sync.WaitGroup114 var wg sync.WaitGroup
111 wg.Add(2)115 wg.Add(2)
112 go func() {116 go func() {
@@ -120,9 +124,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
120 go func() {124 go func() {
121 defer wg.Done()125 defer wg.Done()
122 for range 200 {126 for range 200 {
123- tb.mu.Lock()127+ _, _ = composeCopyClientFrame(mode, pane, target, base, bars)
124- _, _ = composeCopyClientFrame(mode, tb, bars)
125- tb.mu.Unlock()
126 }128 }
127 }()129 }()
128 wg.Wait()130 wg.Wait()
@@ -139,12 +141,15 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
139 snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)141 snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)
140 mode := scopy.NewMode(snap)142 mode := scopy.NewMode(snap)
141 143
142- frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()})144+ bars := barState{status: sess.statusSegments()}
143-145+ base, _ := composeClientFrameWithState(bars, tb, true)
144- require.Equal(t, 12, frame.Width)146+ frame, damage := composeCopyClientFrame(mode, tb.focusedPane(), domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
145- require.Equal(t, 5, frame.Height)147+
146- require.Equal(t, " 1 ", rowText(frame.Row(0)))148+ require.Equal(t, 80, frame.Width)
147- require.Contains(t, rowText(frame.Row(4)), "[SCROLL]")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]")
148 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)153 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
149 }154 }
150 155
@@ -636,3 +641,177 @@ func TestCursorTailHidesInCopyMode(t *testing.T) {
636 data := mustOutputData(t, sends)641 data := mustOutputData(t, sends)
637 require.Contains(t, string(data), "\x1b[?25l")642 require.Contains(t, string(data), "\x1b[?25l")
638 }643 }
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 {
135 bindings atomic.Pointer[keys.Bindings]135 bindings atomic.Pointer[keys.Bindings]
136 codeOverrides atomic.Pointer[map[string]string]136 codeOverrides atomic.Pointer[map[string]string]
137 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]137 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
138+ floatingConfig atomic.Pointer[domain.FloatingConfig]
138 themeMode atomic.Uint32139 themeMode atomic.Uint32
139 barScripts *barScriptState140 barScripts *barScriptState
140 resumeParkGrace time.Duration141 resumeParkGrace time.Duration
@@ -315,6 +316,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
315 running: make(map[domain.SessionID]bool),316 running: make(map[domain.SessionID]bool),
316 },317 },
317 }318 }
319+ defaultFloating := domain.Defaults().Floating
320+ d.floatingConfig.Store(&defaultFloating)
318 for _, o := range opts {321 for _, o := range opts {
319 o(d)322 o(d)
320 }323 }
M internal/usecase/daemon/daemon_test.go +66 -3
@@ -105,10 +105,66 @@ func newBlockingPTYWithWrites(t *testing.T, writes chan<- []byte) (*portsmocks.M
105 return p, unblock105 return p, unblock
106 }106 }
107 107
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.
108 func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {153 func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {
109 t.Helper()154 t.Helper()
110 f := portsmocks.NewMockPTYFactory(t)155 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()
112 return f168 return f
113 }169 }
114 170
@@ -116,13 +172,20 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory {
116 t.Helper()172 t.Helper()
117 f := portsmocks.NewMockPTYFactory(t)173 f := portsmocks.NewMockPTYFactory(t)
118 var mu sync.Mutex174 var mu sync.Mutex
175+ var normal domain.Size
119 next := 0176 next := 0
120 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(177 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) {
122 mu.Lock()179 mu.Lock()
123 defer mu.Unlock()180 defer mu.Unlock()
181+ if !normal.Valid() {
182+ normal = sz
183+ }
184+ if sz != normal {
185+ return newQuietPTY(), nil
186+ }
124 if next >= len(ptys) {187 if next >= len(ptys) {
125- t.Fatalf("unexpected PTY open #%d", next+1)188+ return newQuietPTY(), nil
126 }189 }
127 p := ptys[next]190 p := ptys[next]
128 next++191 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) {
74 if rt.copySearchActive() {74 if rt.copySearchActive() {
75 return75 return
76 }76 }
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+ }
77 if ev.Button == mouse.Left {101 if ev.Button == mouse.Left {
78 contentRow := ev.Row - 1102 contentRow := ev.Row - 1
79 tb.mu.Lock()103 tb.mu.Lock()
@@ -120,6 +144,17 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
120 144
121 tb.mu.Lock()145 tb.mu.Lock()
122 contentRow := ev.Row - 1146 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+ }
123 pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)158 pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)
124 multi := len(tb.panes) > 1159 multi := len(tb.panes) > 1
125 focusedID := layout.PaneID("")160 focusedID := layout.PaneID("")
@@ -177,6 +212,14 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
177 return212 return
178 }213 }
179 }214 }
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
180 p.mu.Lock()223 p.mu.Lock()
181 childRows := p.screen.Frame.Height224 childRows := p.screen.Frame.Height
182 mouseMode, mouseSGR := p.screen.MouseMode()225 mouseMode, mouseSGR := p.screen.MouseMode()
@@ -236,6 +279,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
236 mode.StartSelectionAt(snap, pressTop+pressRow)279 mode.StartSelectionAt(snap, pressTop+pressRow)
237 mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row)280 mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row)
238 rt.copyMode = mode281 rt.copyMode = mode
282+ rt.copyPane = p
239 rt.copyPressRow = pressTop + pressRow283 rt.copyPressRow = pressTop + pressRow
240 rt.copyPressRowValid = true284 rt.copyPressRowValid = true
241 rt.copyDragging = true285 rt.copyDragging = true
@@ -285,7 +329,9 @@ func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) {
285 if _, err := p.pty.Write(data); err != nil {329 if _, err := p.pty.Write(data); err != nil {
286 name := ""330 name := ""
287 if sess != nil {331 if sess != nil {
332+ sess.mu.Lock()
288 name = sess.name333 name = sess.name
334+ sess.mu.Unlock()
289 }335 }
290 d.log.Error("pty write failed", "err", err, "session", name)336 d.log.Error("pty write failed", "err", err, "session", name)
291 }337 }
@@ -348,7 +394,7 @@ func (h daemonKeyHandler) Forward(data []byte) {
348 return394 return
349 }395 }
350 tb.mu.Lock()396 tb.mu.Lock()
351- p := tb.focusedPane()397+ p := tb.terminalTargetLocked()
352 tb.mu.Unlock()398 tb.mu.Unlock()
353 h.d.writeToPane(sess, p, data)399 h.d.writeToPane(sess, p, data)
354 }400 }
@@ -363,6 +409,10 @@ func (h daemonKeyHandler) Action(action keys.Action) {
363 h.d.enterPalette(sess, h.ac)409 h.d.enterPalette(sess, h.ac)
364 case keys.ActionJumpAttention:410 case keys.ActionJumpAttention:
365 h.d.jumpAttention(sess, h.ac)411 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+ }
366 case keys.ActionFocusPaneLeft:416 case keys.ActionFocusPaneLeft:
367 _ = h.d.focusDir(sess, h.ac, layout.Left)417 _ = h.d.focusDir(sess, h.ac, layout.Left)
368 case keys.ActionFocusPaneRight:418 case keys.ActionFocusPaneRight:
@@ -376,6 +426,7 @@ func (h daemonKeyHandler) Action(action keys.Action) {
376 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:426 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
377 idx := int(action - keys.ActionSwitchTab1)427 idx := int(action - keys.ActionSwitchTab1)
378 if sess.switchTab(idx) {428 if sess.switchTab(idx) {
429+ h.d.activateTab(sess, sess.activeTab())
379 h.d.paint(sess, h.ac, true)430 h.d.paint(sess, h.ac, true)
380 }431 }
381 }432 }
M internal/usecase/daemon/input_test.go +179 -0
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "encoding/base64"4 "encoding/base64"
5+ "fmt"
5 "strings"6 "strings"
6 "sync"7 "sync"
7 "testing"8 "testing"
@@ -14,6 +15,7 @@ import (
14 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
15 portsmocks "github.com/bnema/vev/internal/ports/mocks"16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
16 scopy "github.com/bnema/vev/internal/usecase/copy"17 scopy "github.com/bnema/vev/internal/usecase/copy"
18+ "github.com/bnema/vev/internal/usecase/keys"
17 "github.com/bnema/vev/internal/usecase/layout"19 "github.com/bnema/vev/internal/usecase/layout"
18 )20 )
19 21
@@ -60,6 +62,22 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
60 require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond)62 require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond)
61 require.Equal(t, []byte("C"), <-writes3)63 require.Equal(t, []byte("C"), <-writes3)
62 64
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+
63 releaseConn()81 releaseConn()
64 releasePTY1()82 releasePTY1()
65 releasePTY2()83 releasePTY2()
@@ -79,6 +97,157 @@ func TestAltCForwardsToPTY(t *testing.T) {
79 releasePTY()97 releasePTY()
80 }98 }
81 99
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+
82 func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {251 func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
83 writes := make(chan []byte, 1)252 writes := make(chan []byte, 1)
84 p, releasePTY := newBlockingPTYWithWrites(t, writes)253 p, releasePTY := newBlockingPTYWithWrites(t, writes)
@@ -196,6 +365,16 @@ func TestOverlayInputPrecedence(t *testing.T) {
196 }365 }
197 }366 }
198 367
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+
199 func requireNoPTYWrite(t *testing.T, writes chan []byte) {378 func requireNoPTYWrite(t *testing.T, writes chan []byte) {
200 t.Helper()379 t.Helper()
201 select {380 select {
M internal/usecase/daemon/overlay_runtime.go +17 -0
@@ -30,6 +30,7 @@ type overlayRuntime struct {
30 30
31 copyMu sync.Mutex31 copyMu sync.Mutex
32 copyMode *scopy.Mode32 copyMode *scopy.Mode
33+ copyPane *pane
33 copyPending []byte34 copyPending []byte
34 copyESC pendingByteTimer35 copyESC pendingByteTimer
35 copySearch *visualsearch.Model36 copySearch *visualsearch.Model
@@ -101,10 +102,24 @@ func (rt *overlayRuntime) copySearchActive() bool {
101 102
102 func (rt *overlayRuntime) clearCopyModeLocked() {103 func (rt *overlayRuntime) clearCopyModeLocked() {
103 rt.copyMode = nil104 rt.copyMode = nil
105+ rt.copyPane = nil
104 rt.copySearch = nil106 rt.copySearch = nil
105 rt.copySearchPending = nil107 rt.copySearchPending = nil
106 }108 }
107 109
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+
108 func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {123 func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
109 if rt == nil || rt.ac == nil {124 if rt == nil || rt.ac == nil {
110 return false125 return false
@@ -134,6 +149,7 @@ type overlayRenderSnapshot struct {
134 149
135 copyActive bool150 copyActive bool
136 copyMode *scopy.Mode151 copyMode *scopy.Mode
152+ copyPane *pane
137 copySearchModel *visualsearch.Model153 copySearchModel *visualsearch.Model
138 copyFeedback string154 copyFeedback string
139 155
@@ -164,6 +180,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
164 180
165 rt.copyMu.Lock()181 rt.copyMu.Lock()
166 snap.copyActive = rt.copyMode != nil182 snap.copyActive = rt.copyMode != nil
183+ snap.copyPane = rt.copyPane
167 if rt.copyMode != nil {184 if rt.copyMode != nil {
168 copyModeValue := *rt.copyMode185 copyModeValue := *rt.copyMode
169 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)186 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
M internal/usecase/daemon/overlay_runtime_test.go +23 -0
@@ -5,6 +5,7 @@ import (
5 5
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 7
8+ "github.com/bnema/vev/internal/domain"
8 scopy "github.com/bnema/vev/internal/usecase/copy"9 scopy "github.com/bnema/vev/internal/usecase/copy"
9 promptui "github.com/bnema/vev/internal/usecase/prompt"10 promptui "github.com/bnema/vev/internal/usecase/prompt"
10 "github.com/bnema/vev/internal/usecase/visualsearch"11 "github.com/bnema/vev/internal/usecase/visualsearch"
@@ -143,3 +144,25 @@ func TestOverlayRuntimeHandleInputInactive(t *testing.T) {
143 144
144 require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x")))145 require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x")))
145 }146 }
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 {
230 }230 }
231 231
232 func (e paletteExec) NextTab() error {232 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+ }
234 e.d.paint(e.sess, e.ac, true)236 e.d.paint(e.sess, e.ac, true)
235 return nil237 return nil
236 }238 }
237 239
238 func (e paletteExec) PrevTab() error {240 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+ }
240 e.d.paint(e.sess, e.ac, true)244 e.d.paint(e.sess, e.ac, true)
241 return nil245 return nil
242 }246 }
243 247
248+func (e paletteExec) ToggleFloatingPane() error {
249+ return e.d.toggleFloating(e.sess, e.ac)
250+}
251+
244 func (e paletteExec) BackSession() error {252 func (e paletteExec) BackSession() error {
245 e.d.navigateRecentSession(e.sess, e.ac, 1)253 e.d.navigateRecentSession(e.sess, e.ac, 1)
246 return nil254 return nil
M internal/usecase/daemon/palette_test.go +29 -5
@@ -18,6 +18,10 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
18 p1, release1 := newBlockingPTY(t)18 p1, release1 := newBlockingPTY(t)
19 p2, release2 := newBlockingPTY(t)19 p2, release2 := newBlockingPTY(t)
20 d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2)20 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)
21 defer release1()25 defer release1()
22 defer release2()26 defer release2()
23 27
@@ -31,6 +35,7 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
31 d.handleInput(sess, ac, []byte("NXT\r"))35 d.handleInput(sess, ac, []byte("NXT\r"))
32 require.False(t, ac.overlays.paletteActive())36 require.False(t, ac.overlays.paletteActive())
33 require.Equal(t, 1, activeTabIndex(sess))37 require.Equal(t, 1, activeTabIndex(sess))
38+ requireFloatingInitialized(t, sess.activeTab())
34 awaitFrame(t, sends, ports.MsgOutput)39 awaitFrame(t, sends, ports.MsgOutput)
35 40
36 d.handleInput(sess, ac, []byte("\x1b "))41 d.handleInput(sess, ac, []byte("\x1b "))
@@ -40,15 +45,31 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
40 awaitFrame(t, sends, ports.MsgOutput)45 awaitFrame(t, sends, ports.MsgOutput)
41 }46 }
42 47
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+
43 func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {66 func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
44 p1, release1 := newBlockingPTY(t)67 p1, release1 := newBlockingPTY(t)
45 p2, release2 := newBlockingPTY(t)68 p2, release2 := newBlockingPTY(t)
46 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)69 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
47 defer release1()70 defer release1()
48 defer release2()71 defer release2()
49- ptys := portsmocks.NewMockPTYFactory(t)72+ d.ptys = newFactorySeq(t, p2)
50- ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p2, nil).Once()
51- d.ptys = ptys
52 73
53 d.handleInput(sess, ac, []byte("\x1b "))74 d.handleInput(sess, ac, []byte("\x1b "))
54 awaitFrame(t, sends, ports.MsgOutput)75 awaitFrame(t, sends, ports.MsgOutput)
@@ -83,6 +104,9 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
83 104
84 func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {105 func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
85 d, sess, ac, sends, releases := newManualTabSession(t, 2)106 d, sess, ac, sends, releases := newManualTabSession(t, 2)
107+ sess.mu.Lock()
108+ sess.client = ac
109+ sess.mu.Unlock()
86 defer releases[0]()110 defer releases[0]()
87 defer releases[1]()111 defer releases[1]()
88 112
@@ -112,12 +136,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
112 for i, cmd := range commands {136 for i, cmd := range commands {
113 codes[i] = cmd.Code137 codes[i] = cmd.Code
114 }138 }
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)
116 }140 }
117 141
118 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {142 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
119 d := &Daemon{}143 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"}
121 145
122 var wg sync.WaitGroup146 var wg sync.WaitGroup
123 for range 50 {147 for range 50 {
M internal/usecase/daemon/pane.go +20 -16
@@ -3,7 +3,6 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "sync"5 "sync"
6- "time"
7 6
8 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
@@ -17,21 +16,25 @@ import (
17 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.16 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
18 // The PTY reader takes only pane.mu, so child output never waits on client IO.17 // The PTY reader takes only pane.mu, so child output never waits on client IO.
19 type pane struct {18 type pane struct {
20- id layout.PaneID19+ id layout.PaneID
21- stableID string20+ stableID string
22- pty ports.PTY21+ pty ports.PTY
23- mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title22+ mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title
24- screen *vt.Screen23+ resizeMu sync.Mutex // serializes PTY resizes without holding mu
25- scrollback *scopy.Scrollback24+ screen *vt.Screen
26- dirty chan struct{}25+ scrollback *scopy.Scrollback
27- flush chan struct{}26+ dirty chan struct{}
28- syncGen uint6427+ flush chan struct{}
29- rect domain.Rect28+ syncGen uint64
30- title string29+ rect domain.Rect
31- titleAt time.Time30+ popupGeometry floatingGeometry // last geometry committed after a successful floating resize
32- titleValid bool31+ title paneTitleState
33- ctx context.Context32+ ctx context.Context
34- cancel context.CancelFunc33+ 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()
35 }38 }
36 39
37 func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {40 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
51 dirty: make(chan struct{}, 1),54 dirty: make(chan struct{}, 1),
52 flush: make(chan struct{}, 1),55 flush: make(chan struct{}, 1),
53 rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},56 rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
57+ title: paneTitleState{displayFallback: "sh"},
54 }58 }
55 }59 }
56 60
M internal/usecase/daemon/pane_titles.go +93 -14
@@ -10,6 +10,69 @@ import (
10 10
11 const paneTitleCacheTTL = time.Second11 const paneTitleCacheTTL = time.Second
12 12
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+
13 func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {76 func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {
14 return d.refreshPaneTitleCached(sess, id, false)77 return d.refreshPaneTitleCached(sess, id, false)
15 }78 }
@@ -18,11 +81,9 @@ func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID) string
18 return d.refreshPaneTitleCached(sess, id, true)81 return d.refreshPaneTitleCached(sess, id, true)
19 }82 }
20 83
84+// refreshPaneTitleCached retains the ID lookup used by normal layout panes.
21 func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool) string {85 func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool) string {
22- fallback := filepath.Base(d.shell)86+ fallback := d.paneTitleFallback()
23- if fallback == "." || fallback == string(filepath.Separator) || fallback == "" {
24- fallback = d.shell
25- }
26 if sess == nil {87 if sess == nil {
27 return fallback88 return fallback
28 }89 }
@@ -36,29 +97,47 @@ func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force b
36 if p == nil {97 if p == nil {
37 return fallback98 return fallback
38 }99 }
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+ }
39 now := d.clock.Now()111 now := d.clock.Now()
40 p.mu.Lock()112 p.mu.Lock()
41- if !force && p.titleValid && now.Sub(p.titleAt) < paneTitleCacheTTL {113+ if !force && p.title.processNameValid && now.Sub(p.title.processNameAt) < paneTitleCacheTTL {
42- title := p.title114+ title := p.displayTitleLocked()
43 p.mu.Unlock()115 p.mu.Unlock()
44- if title == "" {
45- return fallback
46- }
47 return title116 return title
48 }117 }
49 p.mu.Unlock()118 p.mu.Unlock()
50- title := fallback119+
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 := ""
51 if d.procComm != nil && p.pty != nil {124 if d.procComm != nil && p.pty != nil {
52 if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 {125 if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 {
53 if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" {126 if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" {
54- title = strings.TrimSpace(comm)127+ processName = strings.TrimSpace(comm)
55 }128 }
56 }129 }
57 }130 }
131+
58 p.mu.Lock()132 p.mu.Lock()
59- p.title = title133+ oldTitle := p.displayTitleLocked()
60- p.titleAt = now134+ p.title.processName = processName
61- p.titleValid = true135+ p.title.processNameAt = now
136+ p.title.processNameValid = true
137+ title := p.displayTitleLocked()
138+ if oldTitle != title {
139+ p.title.generation++
140+ }
62 p.mu.Unlock()141 p.mu.Unlock()
63 return title142 return title
64 }143 }
M internal/usecase/daemon/pane_titles_test.go +111 -8
@@ -8,12 +8,47 @@ import (
8 8
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 10
11+ "github.com/bnema/vev/internal/domain"
11 portsmocks "github.com/bnema/vev/internal/ports/mocks"12 portsmocks "github.com/bnema/vev/internal/ports/mocks"
12 )13 )
13 14
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+
14 func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {49 func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
15 pty := portsmocks.NewMockPTY(t)50 pty := portsmocks.NewMockPTY(t)
16- pty.EXPECT().ForegroundPgid().Return(1234, nil).Once()51+ pty.EXPECT().ForegroundPgid().Return(1234, nil).Twice()
17 _, sess, _, _ := newManualSessionWithPTYs(t, pty)52 _, sess, _, _ := newManualSessionWithPTYs(t, pty)
18 clk := portsmocks.NewMockClock(t)53 clk := portsmocks.NewMockClock(t)
19 clk.EXPECT().Now().Return(time.Time{}).Maybe()54 clk.EXPECT().Now().Return(time.Time{}).Maybe()
@@ -25,9 +60,17 @@ func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
25 }60 }
26 61
27 title := d.refreshPaneTitle(sess, "pane-1")62 title := d.refreshPaneTitle(sess, "pane-1")
28-
29 require.Equal(t, "vim", title)63 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)
31 }74 }
32 75
33 func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {76 func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {
@@ -53,18 +96,78 @@ func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {
53 require.Equal(t, int32(2), calls.Load(), "focus refresh should bypass TTL")96 require.Equal(t, int32(2), calls.Load(), "focus refresh should bypass TTL")
54 }97 }
55 98
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) {
57 pty := portsmocks.NewMockPTY(t)149 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()
59 _, sess, _, _ := newManualSessionWithPTYs(t, pty)151 _, sess, _, _ := newManualSessionWithPTYs(t, pty)
60 clk := portsmocks.NewMockClock(t)152 clk := portsmocks.NewMockClock(t)
61 clk.EXPECT().Now().Return(time.Time{}).Maybe()153 clk.EXPECT().Now().Return(time.Time{}).Maybe()
62 d := newTestDaemon(t, nil, clk)154 d := newTestDaemon(t, nil, clk)
63 d.shell = "/usr/bin/fish"155 d.shell = "/usr/bin/fish"
64 d.procComm = func(int) (string, error) { return "", errors.New("unused") }156 d.procComm = func(int) (string, error) { return "", errors.New("unused") }
157+ p := sess.activeTab().focusedPane()
65 158
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()
67 167
68- require.Equal(t, "fish", title)168+ p.mu.Lock()
69- require.Equal(t, "fish", sess.activeTab().focusedPane().title)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")
70 }173 }
M internal/usecase/daemon/picker.go +3 -1
@@ -360,7 +360,9 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
360 return360 return
361 }361 }
362 if targetSess == sess {362 if targetSess == sess {
363- sess.switchTab(target.TabIndex)363+ if sess.switchTab(target.TabIndex) {
364+ d.activateTab(sess, sess.activeTab())
365+ }
364 d.paint(sess, ac, true)366 d.paint(sess, ac, true)
365 return367 return
366 }368 }
M internal/usecase/daemon/picker_test.go +29 -7
@@ -90,6 +90,10 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
90 90
91 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {91 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
92 d, sess, ac, sends, releases := newManualTabSession(t, 2)92 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)
93 defer func() {97 defer func() {
94 for _, release := range releases {98 for _, release := range releases {
95 release()99 release()
@@ -103,6 +107,7 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
103 d.handleInput(sess, ac, []byte("\r"))107 d.handleInput(sess, ac, []byte("\r"))
104 108
105 require.Equal(t, 1, activeTabIndex(sess))109 require.Equal(t, 1, activeTabIndex(sess))
110+ requireFloatingInitialized(t, sess.activeTab())
106 awaitFrame(t, sends, ports.MsgOutput)111 awaitFrame(t, sends, ports.MsgOutput)
107 d.enterPicker(sess, ac)112 d.enterPicker(sess, ac)
108 awaitFrame(t, sends, ports.MsgOutput)113 awaitFrame(t, sends, ports.MsgOutput)
@@ -315,11 +320,14 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
315 var openedEnv []string320 var openedEnv []string
316 f := portsmocks.NewMockPTYFactory(t)321 f := portsmocks.NewMockPTYFactory(t)
317 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(322 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+ }
319 openedEnv = append([]string(nil), env...)327 openedEnv = append([]string(nil), env...)
320 return p3, nil328 return p3, nil
321 },329 },
322- ).Once()330+ ).Maybe()
323 d := newTestDaemon(t, f, stubClock{})331 d := newTestDaemon(t, f, stubClock{})
324 tr1, _ := newCapturingTransport(t)332 tr1, _ := newCapturingTransport(t)
325 tr2, _ := newCapturingTransport(t)333 tr2, _ := newCapturingTransport(t)
@@ -362,12 +370,12 @@ func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {
362 func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {370 func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
363 tb := newTab(nil, domain.Size{Cols: 41, Rows: 5})371 tb := newTab(nil, domain.Size{Cols: 41, Rows: 5})
364 left := tb.focusedPane()372 left := tb.focusedPane()
365- left.title = "one"373+ left.title.processName = "one"
366 left.screen.Write([]byte("L"))374 left.screen.Write([]byte("L"))
367 rightTop := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})375 rightTop := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
368- rightTop.title = "two"376+ rightTop.title.processName = "two"
369 rightBottom := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 2})377 rightBottom := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 2})
370- rightBottom.title = "three"378+ rightBottom.title.processName = "three"
371 rightBottom.screen.Write([]byte("R"))379 rightBottom.screen.Write([]byte("R"))
372 380
373 tb.mu.Lock()381 tb.mu.Lock()
@@ -532,7 +540,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
532 defer release2()540 defer release2()
533 var opens [][]string541 var opens [][]string
534 f := portsmocks.NewMockPTYFactory(t)542 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(
536 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {545 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
537 opens = append(opens, append([]string(nil), env...))546 opens = append(opens, append([]string(nil), env...))
538 if len(opens) == 1 {547 if len(opens) == 1 {
@@ -541,6 +550,10 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
541 return p2, nil550 return p2, nil
542 },551 },
543 ).Twice()552 ).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()
544 d := newTestDaemon(t, f, stubClock{})557 d := newTestDaemon(t, f, stubClock{})
545 d.stopped["old"] = stoppedSession{name: "old", cwd: t.TempDir(), createdAt: 1}558 d.stopped["old"] = stoppedSession{name: "old", cwd: t.TempDir(), createdAt: 1}
546 tr := portsmocks.NewMockTransport(t)559 tr := portsmocks.NewMockTransport(t)
@@ -552,10 +565,19 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
552 got := ac.sess.Get()565 got := ac.sess.Get()
553 require.NotNil(t, got)566 require.NotNil(t, got)
554 got.mu.Lock()567 got.mu.Lock()
555- defer got.mu.Unlock()
556 require.True(t, got.terminal.TrueColor)568 require.True(t, got.terminal.TrueColor)
569+ got.mu.Unlock()
557 require.Len(t, opens, 2)570 require.Len(t, opens, 2)
558 require.Contains(t, opens[1], "TERM=xterm-direct")571 require.Contains(t, opens[1], "TERM=xterm-direct")
559 require.Contains(t, opens[1], "COLORTERM=truecolor")572 require.Contains(t, opens[1], "COLORTERM=truecolor")
560 require.Contains(t, opens[1], "TERM_PROGRAM=vev")573 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+ }
561 }583 }
M internal/usecase/daemon/render.go +122 -26
@@ -69,6 +69,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
69 p.mu.Lock()69 p.mu.Lock()
70 wasSyncing := p.screen.SyncUpdateActive()70 wasSyncing := p.screen.SyncUpdateActive()
71 p.screen.Write(data)71 p.screen.Write(data)
72+ p.refreshTerminalTitleLocked()
72 isSyncing := p.screen.SyncUpdateActive()73 isSyncing := p.screen.SyncUpdateActive()
73 p.mu.Unlock()74 p.mu.Unlock()
74 markSnapshotDirty(sess)75 markSnapshotDirty(sess)
@@ -108,7 +109,11 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
108 signal(p.dirty)109 signal(p.dirty)
109 }110 }
110 if err != nil {111 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+ }
112 return117 return
113 }118 }
114 }119 }
@@ -214,9 +219,6 @@ func syncUpdateEndIn(data []byte) bool {
214 // render paints the current client, or (when detached) just clears accumulated219 // render paints the current client, or (when detached) just clears accumulated
215 // damage so it never grows unbounded while headless.220 // damage so it never grows unbounded while headless.
216 func (d *Daemon) render(sess *session, tb *tab, p *pane) {221 func (d *Daemon) render(sess *session, tb *tab, p *pane) {
217- tb.mu.Lock()
218- previewer := tb.previewClient
219- tb.mu.Unlock()
220 if p == nil {222 if p == nil {
221 return223 return
222 }224 }
@@ -227,6 +229,20 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) {
227 }229 }
228 p.mu.Unlock()230 p.mu.Unlock()
229 231
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+
230 sess.mu.Lock()246 sess.mu.Lock()
231 ac := sess.client247 ac := sess.client
232 active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb248 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) {
259 // paint draws the composed client frame (active tab plus status bar) and275 // paint draws the composed client frame (active tab plus status bar) and
260 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations276 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
261 // such as switch/create/close/rename/resize so the repaint is complete.277 // 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+
262 func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID {306 func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID {
263 if !ok {307 if !ok {
264 return nil308 return nil
@@ -313,10 +357,15 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
313 tb.mu.Lock()357 tb.mu.Lock()
314 layoutSnap := solveTabLayoutLocked(tb)358 layoutSnap := solveTabLayoutLocked(tb)
315 titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok)359 titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok)
360+ floating := tb.floating.pane
361+ hasFloating := tb.floating.state == floatingVisible && floating != nil
316 tb.mu.Unlock()362 tb.mu.Unlock()
317 for _, id := range titleIDs {363 for _, id := range titleIDs {
318 d.refreshPaneTitle(sess, id)364 d.refreshPaneTitle(sess, id)
319 }365 }
366+ if hasFloating {
367+ d.refreshPaneDisplayTitle(sess, floating, false)
368+ }
320 369
321 tb.mu.Lock()370 tb.mu.Lock()
322 if !layoutSnap.matchesLocked(tb) {371 if !layoutSnap.matchesLocked(tb) {
@@ -328,6 +377,12 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
328 ac.sendMu.Unlock()377 ac.sendMu.Unlock()
329 return378 return
330 }379 }
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()
331 overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive386 overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
332 if reset || overlayActive {387 if reset || overlayActive {
333 ac.bars.Reset()388 ac.bars.Reset()
@@ -343,11 +398,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
343 } else {398 } else {
344 frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)399 frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)
345 }400 }
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+ }
346 if overlays.copyActive {405 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)
348 }412 }
349- if overlays.paletteActive {413+ // A palette above normal/copy content dims that composed content. When a
350- contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}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 {
351 (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme)417 (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme)
352 }418 }
353 if overlays.copySearchModel != nil {419 if overlays.copySearchModel != nil {
@@ -371,10 +437,19 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
371 frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)437 frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)
372 }438 }
373 overlays.Unlock()439 overlays.Unlock()
440+ cursorPane := p
374 cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)441 cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)
375- p.mu.Lock()442+ if hasFloating {
376- desiredCursor := desiredCursorOut(p.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive)443+ cursorPane = floating
377- p.mu.Unlock()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()
378 ac.sess.mu.Lock()453 ac.sess.mu.Lock()
379 if ac.sess.v != sess {454 if ac.sess.v != sess {
380 ac.sess.mu.Unlock()455 ac.sess.mu.Unlock()
@@ -511,13 +586,23 @@ func (c *barCache) Reset() {
511 }586 }
512 587
513 type composedFrameCache struct {588 type composedFrameCache struct {
514- frame renderer.Frame589+ frame renderer.Frame
515- valid bool590+ valid bool
516- layoutSnap tabLayoutSnapshot591+ layoutSnap tabLayoutSnapshot
592+ titleGenerations map[layout.PaneID]uint64
593+ floating *pane
594+ floatingFrame renderer.Frame
595+ floatingGeneration uint64
596+ floatingGeometry floatingGeometry
597+ floatingTitleGeneration uint64
517 }598 }
518 599
519 func (c *composedFrameCache) invalidate() {600 func (c *composedFrameCache) invalidate() {
520 c.valid = false601 c.valid = false
602+ c.floating = nil
603+ c.floatingGeneration = 0
604+ c.floatingGeometry = floatingGeometry{}
605+ c.floatingTitleGeneration = 0
521 }606 }
522 607
523 func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {608 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
580 }665 }
581 topBar := frame.Row(0)666 topBar := frame.Row(0)
582 drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)667 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)
584 bottomBar := frame.Row(screenRows + 1)676 bottomBar := frame.Row(screenRows + 1)
585 drawStatusBarState(bottomBar, bars, styles)677 drawStatusBarState(bottomBar, bars, styles)
586 if composed != nil {678 if composed != nil {
@@ -618,10 +710,10 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l
618 }710 }
619 711
620 func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {712 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)
622 }714 }
623 715
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 {
625 contentArea := domain.Rect{Width: area.Width, Height: area.Height}717 contentArea := domain.Rect{Width: area.Width, Height: area.Height}
626 root := tb.tree.Root718 root := tb.tree.Root
627 placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea719 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
636 }728 }
637 placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}729 placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
638 }730 }
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+ }
639 if ok && !cacheValid {736 if ok && !cacheValid {
640 drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))737 drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
641 }738 }
@@ -651,13 +748,16 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do
651 focused := tb.tree.Focus == pl.ID748 focused := tb.tree.Focus == pl.ID
652 pl = offsetPlacement(pl, area.X, area.Y)749 pl = offsetPlacement(pl, area.X, area.Y)
653 if pl.TitleBar.Height > 0 {750 if pl.TitleBar.Height > 0 {
654- drawPaneTitleBar(frame, pl, p, focused, theme, "")751+ generation := drawPaneTitleBar(frame, pl, p, focused, theme)
655- if cacheValid {752+ if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
656 titleDamage := pl.TitleBar753 titleDamage := pl.TitleBar
657 titleDamage.X -= area.X754 titleDamage.X -= area.X
658 titleDamage.Y -= area.Y755 titleDamage.Y -= area.Y
659 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})756 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
660 }757 }
758+ if titleGenerations != nil {
759+ titleGenerations[pl.ID] = generation
760+ }
661 }761 }
662 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {762 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
663 if consumeDamage {763 if consumeDamage {
@@ -721,7 +821,7 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo
721 }821 }
722 }822 }
723 823
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 {
725 styles := newThemeStyles(theme)825 styles := newThemeStyles(theme)
726 style := styles.border826 style := styles.border
727 if focused {827 if focused {
@@ -733,15 +833,11 @@ func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focuse
733 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})833 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
734 }834 }
735 p.mu.Lock()835 p.mu.Lock()
736- title := p.title836+ title := p.displayTitleLocked()
837+ generation := p.title.generation
737 p.mu.Unlock()838 p.mu.Unlock()
738- if title == "" {
739- title = fallback
740- }
741- if title == "" {
742- title = string(p.id)
743- }
744 ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)839 ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
840+ return generation
745 }841 }
746 842
747 func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {843 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) {
631 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)631 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
632 }632 }
633 633
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+
634 func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) {651 func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) {
635 win := newTab(nil, domain.Size{Cols: 81, Rows: 24})652 win := newTab(nil, domain.Size{Cols: 81, Rows: 24})
636 left := win.focusedPane()653 left := win.focusedPane()
@@ -661,13 +678,24 @@ func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) {
661 }678 }
662 }679 }
663 680
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+
664 func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {692 func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
665 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})693 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
666 p1 := win.focusedPane()694 p1 := win.focusedPane()
667- p1.title = "one"695+ p1.title.processName = "one"
668 p1.screen.ClearDamage()696 p1.screen.ClearDamage()
669 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})697 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
670- p2.title = "two"698+ p2.title.processName = "two"
671 p2.screen.Write([]byte("T"))699 p2.screen.Write([]byte("T"))
672 p2.screen.ClearDamage()700 p2.screen.ClearDamage()
673 701
@@ -688,9 +716,9 @@ func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
688 func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {716 func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
689 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})717 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
690 p1 := win.focusedPane()718 p1 := win.focusedPane()
691- p1.title = "one"719+ p1.title.processName = "one"
692 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})720 p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
693- p2.title = "two"721+ p2.title.processName = "two"
694 p2.screen.Write([]byte("T"))722 p2.screen.Write([]byte("T"))
695 win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}723 win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
696 win.tree.Focus = p2.id724 win.tree.Focus = p2.id
@@ -702,12 +730,16 @@ func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
702 p1.screen.ClearDamage()730 p1.screen.ClearDamage()
703 p2.screen.ClearDamage()731 p2.screen.ClearDamage()
704 732
705- p1.title = "renamed"733+ p1.title.processName = "renamed"
734+ p1.title.generation++
706 frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)735 frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
707 736
708 require.Equal(t, "renamed", rowText(frame.Row(1))[:7])737 require.Equal(t, "renamed", rowText(frame.Row(1))[:7])
709 require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset")738 require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset")
710 require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})739 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")
711 }743 }
712 744
713 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {745 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
@@ -1391,3 +1423,79 @@ func TestHistoryNavBottomBar(t *testing.T) {
1391 require.True(t, row[docsCol].Style.Equal(styles.accent))1423 require.True(t, row[docsCol].Style.Equal(styles.accent))
1392 })1424 })
1393 }1425 }
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 {
55 cancel context.CancelFunc55 cancel context.CancelFunc
56 56
57 mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state57 mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state
58+ themeMu sync.Mutex
58 tabs []*tab59 tabs []*tab
59 active int60 active int
60 client *attachedClient61 client *attachedClient
@@ -74,7 +75,7 @@ type session struct {
74 75
75 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.76 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
76 type tab struct {77 type tab struct {
77- mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership78+ mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, previewClient, and pane map membership
78 79
79 stableID string80 stableID string
80 name string81 name string
@@ -84,6 +85,8 @@ type tab struct {
84 size domain.Size85 size domain.Size
85 ctx context.Context86 ctx context.Context
86 cancel context.CancelFunc87 cancel context.CancelFunc
88+ // floating is independent from the normal layout tree and pane map.
89+ floating floatingSlot
87 90
88 // previewClient tracks the one client currently previewing this tab in the picker.91 // previewClient tracks the one client currently previewing this tab in the picker.
89 // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.92 // 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 {
330 d.log.Info("tab created", "session", name, "tab", tabIndex)333 d.log.Info("tab created", "session", name, "tab", tabIndex)
331 d.startTabGoroutines(sess, tb)334 d.startTabGoroutines(sess, tb)
332 d.mu.Unlock()335 d.mu.Unlock()
336+ d.activateTab(sess, tb)
333 markSnapshotDirty(sess)337 markSnapshotDirty(sess)
334 return nil338 return nil
335 }339 }
@@ -371,7 +375,13 @@ func (tb *tab) panesSnapshot() []*pane {
371 func (tb *tab) closeAllPanes() {375 func (tb *tab) closeAllPanes() {
372 tb.mu.Lock()376 tb.mu.Lock()
373 panes := tb.panesSnapshot()377 panes := tb.panesSnapshot()
378+ // Invalidate before cancellation so a concurrently unblocked floating
379+ // reader cannot reap a subsequently reused slot.
380+ floating := tb.takeFloatingLocked()
374 tb.mu.Unlock()381 tb.mu.Unlock()
382+ if floating != nil {
383+ panes = append(panes, floating)
384+ }
375 for _, p := range panes {385 for _, p := range panes {
376 if p.cancel != nil {386 if p.cancel != nil {
377 p.cancel()387 p.cancel()
@@ -401,7 +411,10 @@ func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
401 411
402 func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {412 func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
403 if p != nil {413 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)
405 }418 }
406 d.sessWg.Add(2)419 d.sessWg.Add(2)
407 go d.ptyReader(sess, tb, p)420 go d.ptyReader(sess, tb, p)
@@ -599,12 +612,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
599 return612 return
600 }613 }
601 ringing := tb.attention614 ringing := tb.attention
615+ wasActive := idx == sess.active
602 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)616 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
603 if sess.active >= len(sess.tabs) {617 if sess.active >= len(sess.tabs) {
604 sess.active = len(sess.tabs) - 1618 sess.active = len(sess.tabs) - 1
605 } else if idx < sess.active {619 } else if idx < sess.active {
606 sess.active--620 sess.active--
607 }621 }
622+ destination := sess.tabs[sess.active]
608 ac := sess.client623 ac := sess.client
609 name := sess.name624 name := sess.name
610 record := sess.persistRecordLocked(time.Now().UnixNano())625 record := sess.persistRecordLocked(time.Now().UnixNano())
@@ -623,7 +638,11 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
623 if tb.cancel != nil {638 if tb.cancel != nil {
624 tb.cancel()639 tb.cancel()
625 }640 }
641+ d.teardownFloating(tb, ac)
626 tb.closeAllPanes()642 tb.closeAllPanes()
643+ if wasActive {
644+ d.activateTab(sess, destination)
645+ }
627 if repaint && ac != nil {646 if repaint && ac != nil {
628 d.paint(sess, ac, true)647 d.paint(sess, ac, true)
629 }648 }
@@ -723,6 +742,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
723 sess.mu.Unlock()742 sess.mu.Unlock()
724 for _, tb := range tabs {743 for _, tb := range tabs {
725 d.clearDestroyedTabPreview(tb)744 d.clearDestroyedTabPreview(tb)
745+ d.teardownFloating(tb, ac)
726 tb.closeAllPanes()746 tb.closeAllPanes()
727 }747 }
728 for _, path := range clipFiles {748 for _, path := range clipFiles {
M internal/usecase/daemon/session_test.go +74 -7
@@ -33,7 +33,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
33 defer releaseSecond()33 defer releaseSecond()
34 f := portsmocks.NewMockPTYFactory(t)34 f := portsmocks.NewMockPTYFactory(t)
35 var dirs []string35 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(
37 func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {38 func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
38 dirs = append(dirs, dir)39 dirs = append(dirs, dir)
39 if len(dirs) == 1 {40 if len(dirs) == 1 {
@@ -42,6 +43,10 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
42 return second, nil43 return second, nil
43 },44 },
44 ).Twice()45 ).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()
45 50
46 d := newTestDaemon(t, f, stubClock{})51 d := newTestDaemon(t, f, stubClock{})
47 tr := portsmocks.NewMockTransport(t)52 tr := portsmocks.NewMockTransport(t)
@@ -62,6 +67,16 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
62 67
63 require.NoError(t, d.createTab(sess, sz))68 require.NoError(t, d.createTab(sess, sz))
64 require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs)69 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+ }
65 }80 }
66 81
67 func TestHandshakeEphemeralHappy(t *testing.T) {82 func TestHandshakeEphemeralHappy(t *testing.T) {
@@ -197,8 +212,13 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
197 p2.EXPECT().Pid().Return(4242).Maybe()212 p2.EXPECT().Pid().Return(4242).Maybe()
198 213
199 f := portsmocks.NewMockPTYFactory(t)214 f := portsmocks.NewMockPTYFactory(t)
200- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p1, nil).Once()215+ normalSize := domain.Size{Cols: 80, Rows: 22}
201- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(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(
202 func(string, []string, []string, string, domain.Size) (ports.PTY, error) {222 func(string, []string, []string, string, domain.Size) (ports.PTY, error) {
203 close(opened)223 close(opened)
204 <-releaseOpen224 <-releaseOpen
@@ -231,6 +251,11 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
231 releaseConn()251 releaseConn()
232 hg.Wait()252 hg.Wait()
233 d.sessWg.Wait()253 d.sessWg.Wait()
254+ select {
255+ case <-floating.done:
256+ default:
257+ t.Fatal("floating prewarm PTY was not closed")
258+ }
234 }259 }
235 260
236 // --- ephemeral numbering ----------------------------------------------------261 // --- ephemeral numbering ----------------------------------------------------
@@ -885,6 +910,29 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
885 }910 }
886 }911 }
887 912
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+
888 func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {936 func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
889 sz := domain.Size{Cols: 80, Rows: 24}937 sz := domain.Size{Cols: 80, Rows: 24}
890 p, release := newBlockingPTY(t)938 p, release := newBlockingPTY(t)
@@ -1194,7 +1242,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1194 1242
1195 var opens [][]string1243 var opens [][]string
1196 f := portsmocks.NewMockPTYFactory(t)1244 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(
1198 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1247 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1199 opens = append(opens, append([]string(nil), env...))1248 opens = append(opens, append([]string(nil), env...))
1200 if len(opens) == 1 {1249 if len(opens) == 1 {
@@ -1203,6 +1252,10 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1203 return p2, nil1252 return p2, nil
1204 },1253 },
1205 ).Twice()1254 ).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()
1206 1259
1207 d := newTestDaemon(t, f, stubClock{})1260 d := newTestDaemon(t, f, stubClock{})
1208 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}1261 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}
@@ -1234,7 +1287,8 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1234 1287
1235 var opens [][]string1288 var opens [][]string
1236 f := portsmocks.NewMockPTYFactory(t)1289 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(
1238 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1292 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1239 opens = append(opens, append([]string(nil), env...))1293 opens = append(opens, append([]string(nil), env...))
1240 if len(opens) == 1 {1294 if len(opens) == 1 {
@@ -1243,6 +1297,10 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1243 return p2, nil1297 return p2, nil
1244 },1298 },
1245 ).Twice()1299 ).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()
1246 1304
1247 d := newTestDaemon(t, f, stubClock{})1305 d := newTestDaemon(t, f, stubClock{})
1248 tr1 := portsmocks.NewMockTransport(t)1306 tr1 := portsmocks.NewMockTransport(t)
@@ -1276,7 +1334,8 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1276 defer release2()1334 defer release2()
1277 var opens [][]string1335 var opens [][]string
1278 f := portsmocks.NewMockPTYFactory(t)1336 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(
1280 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1339 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1281 opens = append(opens, append([]string(nil), env...))1340 opens = append(opens, append([]string(nil), env...))
1282 if len(opens) == 1 {1341 if len(opens) == 1 {
@@ -1285,6 +1344,10 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1285 return p2, nil1344 return p2, nil
1286 },1345 },
1287 ).Twice()1346 ).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()
1288 d := newTestDaemon(t, f, stubClock{})1351 d := newTestDaemon(t, f, stubClock{})
1289 tr := portsmocks.NewMockTransport(t)1352 tr := portsmocks.NewMockTransport(t)
1290 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()1353 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
@@ -1295,10 +1358,14 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1295 got := ac.sess.Get()1358 got := ac.sess.Get()
1296 require.NotNil(t, got)1359 require.NotNil(t, got)
1297 got.mu.Lock()1360 got.mu.Lock()
1298- defer got.mu.Unlock()
1299 require.True(t, got.terminal.TrueColor)1361 require.True(t, got.terminal.TrueColor)
1362+ got.mu.Unlock()
1300 require.Len(t, opens, 2)1363 require.Len(t, opens, 2)
1301 require.Contains(t, opens[1], "TERM=xterm-direct")1364 require.Contains(t, opens[1], "TERM=xterm-direct")
1302 require.Contains(t, opens[1], "COLORTERM=truecolor")1365 require.Contains(t, opens[1], "COLORTERM=truecolor")
1303 require.Contains(t, opens[1], "TERM_PROGRAM=vev")1366 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1367+ _ = d.killSession(got, ports.ReasonSessionKilled, false)
1368+ release1()
1369+ release2()
1370+ d.sessWg.Wait()
1304 }1371 }
M internal/usecase/daemon/snapshot_restore_test.go +31 -0
@@ -105,6 +105,37 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
105 require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)105 require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)
106 }106 }
107 107
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+
108 func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {139 func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {
109 t.Helper()140 t.Helper()
110 return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{141 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) {
63 }63 }
64 }64 }
65 65
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+
66 func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {90 func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {
67 store := portsmocks.NewMockSnapshotStore(t)91 store := portsmocks.NewMockSnapshotStore(t)
68 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})92 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) {
155 assertSessionColorScheme(t, sess, true)155 assertSessionColorScheme(t, sess, true)
156 }156 }
157 157
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+
158 func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) {183 func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) {
159 tests := []struct {184 tests := []struct {
160 name string185 name string
@@ -297,26 +322,36 @@ func TestApplyThemeAutoUnknownDoesNotClobberPaneColorScheme(t *testing.T) {
297 func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB) {322 func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB) {
298 t.Helper()323 t.Helper()
299 for _, tb := range sess.tabs {324 for _, tb := range sess.tabs {
300- var got []byte325+ assertPaneDefaultColors(t, tb.focusedPane(), fg, bg)
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))
305 }326 }
306 }327 }
307 328
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+
308 func assertSessionColorScheme(t *testing.T, sess *session, light bool) {338 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) {
309 t.Helper()346 t.Helper()
310 want := "\x1b[?997;1n"347 want := "\x1b[?997;1n"
311 if light {348 if light {
312 want = "\x1b[?997;2n"349 want = "\x1b[?997;2n"
313 }350 }
314- for _, tb := range sess.tabs {351+ var got []byte
315- var got []byte352+ p.screen.OnResponse = func(b []byte) { got = append(got, b...) }
316- tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }353+ p.screen.Write([]byte("\x1b[?996n"))
317- tb.focusedPane().screen.Write([]byte("\x1b[?996n"))354+ require.Equal(t, want, string(got))
318- require.Equal(t, want, string(got))
319- }
320 }355 }
321 356
322 func assertSessionColorSchemeUnknown(t *testing.T, sess *session) {357 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
7 ac.sendMu.Lock()7 ac.sendMu.Lock()
8 defer ac.sendMu.Unlock()8 defer ac.sendMu.Unlock()
9 }9 }
10+ sess.themeMu.Lock()
11+ defer sess.themeMu.Unlock()
12+ return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme)
13+}
10 14
15+func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
11 sess.mu.Lock()16 sess.mu.Lock()
12 if ac != nil {17 if ac != nil {
13 if sess.client != ac {18 if sess.client != ac {
@@ -28,6 +33,9 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
28 for _, tb := range tabs {33 for _, tb := range tabs {
29 tb.mu.Lock()34 tb.mu.Lock()
30 panes := tb.panesSnapshot()35 panes := tb.panesSnapshot()
36+ if floating := tb.floating.pane; floating != nil {
37+ panes = append(panes, floating)
38+ }
31 tb.mu.Unlock()39 tb.mu.Unlock()
32 for _, p := range panes {40 for _, p := range panes {
33 p.mu.Lock()41 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 {
17 altRunes := map[rune]Action{17 altRunes := map[rune]Action{
18 ' ': ActionOpenPalette,18 ' ': ActionOpenPalette,
19 'a': ActionJumpAttention,19 'a': ActionJumpAttention,
20+ 'f': ActionToggleFloatingPane,
20 'h': ActionFocusPaneLeft,21 'h': ActionFocusPaneLeft,
21 'j': ActionFocusPaneDown,22 'j': ActionFocusPaneDown,
22 'k': ActionFocusPaneUp,23 'k': ActionFocusPaneUp,
M internal/usecase/keys/keyspec.go +16 -15
@@ -49,21 +49,22 @@ func ParseKeySpec(spec string) (KeySpec, error) {
49 }49 }
50 50
51 var actionNames = map[Action]string{51 var actionNames = map[Action]string{
52- ActionOpenPalette: "open-palette",52+ ActionOpenPalette: "open-palette",
53- ActionJumpAttention: "jump-attention",53+ ActionToggleFloatingPane: "toggle-floating-pane",
54- ActionFocusPaneLeft: "focus-pane-left",54+ ActionJumpAttention: "jump-attention",
55- ActionFocusPaneRight: "focus-pane-right",55+ ActionFocusPaneLeft: "focus-pane-left",
56- ActionFocusPaneUp: "focus-pane-up",56+ ActionFocusPaneRight: "focus-pane-right",
57- ActionFocusPaneDown: "focus-pane-down",57+ ActionFocusPaneUp: "focus-pane-up",
58- ActionSwitchTab1: "switch-tab-1",58+ ActionFocusPaneDown: "focus-pane-down",
59- ActionSwitchTab2: "switch-tab-2",59+ ActionSwitchTab1: "switch-tab-1",
60- ActionSwitchTab3: "switch-tab-3",60+ ActionSwitchTab2: "switch-tab-2",
61- ActionSwitchTab4: "switch-tab-4",61+ ActionSwitchTab3: "switch-tab-3",
62- ActionSwitchTab5: "switch-tab-5",62+ ActionSwitchTab4: "switch-tab-4",
63- ActionSwitchTab6: "switch-tab-6",63+ ActionSwitchTab5: "switch-tab-5",
64- ActionSwitchTab7: "switch-tab-7",64+ ActionSwitchTab6: "switch-tab-6",
65- ActionSwitchTab8: "switch-tab-8",65+ ActionSwitchTab7: "switch-tab-7",
66- ActionSwitchTab9: "switch-tab-9",66+ ActionSwitchTab8: "switch-tab-8",
67+ ActionSwitchTab9: "switch-tab-9",
67 }68 }
68 69
69 var actionsByName = func() map[string]Action {70 var actionsByName = func() map[string]Action {
M internal/usecase/keys/keyspec_test.go +7 -3
@@ -64,6 +64,7 @@ func TestActionNamesAreCanonical(t *testing.T) {
64 name string64 name string
65 }{65 }{
66 {ActionOpenPalette, "open-palette"},66 {ActionOpenPalette, "open-palette"},
67+ {ActionToggleFloatingPane, "toggle-floating-pane"},
67 {ActionJumpAttention, "jump-attention"},68 {ActionJumpAttention, "jump-attention"},
68 {ActionFocusPaneLeft, "focus-pane-left"},69 {ActionFocusPaneLeft, "focus-pane-left"},
69 {ActionFocusPaneRight, "focus-pane-right"},70 {ActionFocusPaneRight, "focus-pane-right"},
@@ -96,14 +97,17 @@ func TestActionNamesAreCanonical(t *testing.T) {
96 97
97 func TestBuildBindingsAppliesOverrides(t *testing.T) {98 func TestBuildBindingsAppliesOverrides(t *testing.T) {
98 bindings, warnings := BuildBindings(map[string]string{99 bindings, warnings := BuildBindings(map[string]string{
99- "open-palette": "alt+o",100+ "open-palette": "alt+o",
100- "focus-pane-left": "alt+left",101+ "toggle-floating-pane": "alt+g",
101- "switch-tab-2": "alt+2",102+ "focus-pane-left": "alt+left",
103+ "switch-tab-2": "alt+2",
102 })104 })
103 require.Empty(t, warnings)105 require.Empty(t, warnings)
104 106
105 assertAltRuneAction(t, bindings, 'o', ActionOpenPalette)107 assertAltRuneAction(t, bindings, 'o', ActionOpenPalette)
106 assertAltRuneUnbound(t, bindings, ' ')108 assertAltRuneUnbound(t, bindings, ' ')
109+ assertAltRuneAction(t, bindings, 'g', ActionToggleFloatingPane)
110+ assertAltRuneUnbound(t, bindings, 'f')
107 assertAltArrowAction(t, bindings, 'D', ActionFocusPaneLeft)111 assertAltArrowAction(t, bindings, 'D', ActionFocusPaneLeft)
108 assertAltRuneUnbound(t, bindings, 'h')112 assertAltRuneUnbound(t, bindings, 'h')
109 assertAltRuneAction(t, bindings, '2', ActionSwitchTab2)113 assertAltRuneAction(t, bindings, '2', ActionSwitchTab2)
M internal/usecase/keys/router.go +1 -0
@@ -37,6 +37,7 @@ const (
37 ActionFocusPaneRight37 ActionFocusPaneRight
38 ActionFocusPaneUp38 ActionFocusPaneUp
39 ActionFocusPaneDown39 ActionFocusPaneDown
40+ ActionToggleFloatingPane
40 )41 )
41 42
42 // Handler receives router outputs. Forward is called only for bytes that should43 // 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) {
53 want Action53 want Action
54 }{54 }{
55 {name: "palette", in: []byte{ESC, ' '}, want: ActionOpenPalette},55 {name: "palette", in: []byte{ESC, ' '}, want: ActionOpenPalette},
56+ {name: "floating pane", in: []byte{ESC, 'f'}, want: ActionToggleFloatingPane},
56 {name: "jump attention", in: []byte{ESC, 'a'}, want: ActionJumpAttention},57 {name: "jump attention", in: []byte{ESC, 'a'}, want: ActionJumpAttention},
57 {name: "focus left", in: []byte{ESC, 'h'}, want: ActionFocusPaneLeft},58 {name: "focus left", in: []byte{ESC, 'h'}, want: ActionFocusPaneLeft},
58 {name: "focus down", in: []byte{ESC, 'j'}, want: ActionFocusPaneDown},59 {name: "focus down", in: []byte{ESC, 'j'}, want: ActionFocusPaneDown},
@@ -237,6 +238,13 @@ func switchTabActions() []Action {
237 }238 }
238 }239 }
239 240
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+
240 func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {248 func TestRouterInterceptsAltAForJumpAttention(t *testing.T) {
241 clk := &fakeClock{}249 clk := &fakeClock{}
242 h := &captureHandler{}250 h := &captureHandler{}
M pkg/vt/screen.go +11 -3
@@ -84,6 +84,7 @@ type Screen struct {
84 defaultFG renderer.RGB84 defaultFG renderer.RGB
85 defaultBG renderer.RGB85 defaultBG renderer.RGB
86 defaultColorsKnown bool86 defaultColorsKnown bool
87+ terminalTitle string
87 88
88 damage []renderer.Damage89 damage []renderer.Damage
89 escapeBuf []byte90 escapeBuf []byte
@@ -218,6 +219,9 @@ func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyle
218 func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR }219 func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR }
219 func (s *Screen) AltScreenActive() bool { return s.alternate != nil }220 func (s *Screen) AltScreenActive() bool { return s.alternate != nil }
220 221
222+// TerminalTitle returns the latest title set by OSC 0 or OSC 2.
223+func (s *Screen) TerminalTitle() string { return s.terminalTitle }
224+
221 func (s *Screen) Write(data []byte) {225 func (s *Screen) Write(data []byte) {
222 if len(s.escapeBuf) > 0 {226 if len(s.escapeBuf) > 0 {
223 combined := make([]byte, 0, len(s.escapeBuf)+len(data))227 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
@@ -735,15 +739,19 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte {
735 }739 }
736 740
737 // handleOSC inspects a complete OSC payload (between "ESC ]" and its741 // handleOSC inspects a complete OSC payload (between "ESC ]" and its
738-// terminator). Notification sequences (OSC 9, OSC 777 "notify") and clipboard742+// terminator). Terminal titles, notification sequences (OSC 9, OSC 777
739-// set requests (OSC 52) are acted on; titles and every other OSC are still743+// "notify"), and clipboard set requests (OSC 52) are acted on; every other OSC
740-// discarded.744+// is discarded.
741 func (s *Screen) handleOSC(payload []byte) {745 func (s *Screen) handleOSC(payload []byte) {
742 if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {746 if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
743 s.handleOSC52(string(payload[len("52;"):]))747 s.handleOSC52(string(payload[len("52;"):]))
744 return748 return
745 }749 }
746 p := string(payload)750 p := string(payload)
751+ if selector, title, ok := strings.Cut(p, ";"); ok && (selector == "0" || selector == "2") {
752+ s.terminalTitle = title
753+ return
754+ }
747 if p == "9;4" || strings.HasPrefix(p, "9;4;") {755 if p == "9;4" || strings.HasPrefix(p, "9;4;") {
748 s.handleProgress(p[len("9;4"):])756 s.handleProgress(p[len("9;4"):])
749 return757 return
M pkg/vt/screen_test.go +64 -0
@@ -185,6 +185,70 @@ func TestBELWithoutCallbackIsDiscarded(t *testing.T) {
185 require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " "))185 require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
186 }186 }
187 187
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+
188 func TestOnNotifyOSC9(t *testing.T) {252 func TestOnNotifyOSC9(t *testing.T) {
189 s := NewScreen(10, 2)253 s := NewScreen(10, 2)
190 var gotTitle, gotBody string254 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