feat(focus): resize session content to the client whose window gains focus
- commit
- 27972fdb88ed4296b70349f97f6250c7d3e3b3c4
- author
- brice · · 2026-09-26 06:26 UTC
- committer
- brice · · 2026-09-26 08:37 UTC
- parent
- 7b5f8cf
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +47 -4
M docs/architecture.md +1 -1
| @@ -92,7 +92,7 @@ Attention remains a daemon fact. The broker publishes it from fresh observations | |||
| 92 | 92 | ||
| 93 | 93 | ### Terminal focus | |
| 94 | 94 | ||
| 95 | - | The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. | |
| 95 | + | The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window. | |
| 96 | 96 | ||
| 97 | 97 | ## Broker-owned hosts and daemon composition | |
| 98 | 98 | ||
| @@ -92,7 +92,7 @@ Attention remains a daemon fact. The broker publishes it from fresh observations | |||||
| 92 | 92 | ||||
| 93 | ### Terminal focus | 93 | ### Terminal focus | ||
| 94 | 94 | ||||
| 95 | - | The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. | 95 | + | The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window. |
| 96 | 96 | ||||
| 97 | ## Broker-owned hosts and daemon composition | 97 | ## Broker-owned hosts and daemon composition | ||
| 98 | 98 | ||||
M docs/session-navigation.md +1 -1
| @@ -29,7 +29,7 @@ Open it with `SSP` in the palette. It lists local sessions and, if you added hos | |||
| 29 | 29 | ||
| 30 | 30 | Recent-session history belongs to one client and is not saved. A new client starts with an empty history. A session that rings a bell also shows in the history with its bell, after the sessions this client visited, until any client sees it. | |
| 31 | 31 | ||
| 32 | - | A vev window only counts as seeing a bell while it has focus. With several vev windows open, a window on another workspace or behind another app never clears a bell, so the window you are using still shows it. This needs a terminal with focus reporting, such as kitty, foot, WezTerm, or Ghostty; other terminals keep treating every open window as seen. | |
| 32 | + | A vev window only counts as seeing a bell while it has focus. With several vev windows open, a window on another workspace or behind another app never clears a bell, so the window you are using still shows it. This needs a terminal with focus reporting, such as kitty, foot, WezTerm, or Ghostty; other terminals keep treating every open window as seen. Focusing a vev window also resizes the session content to that window. | |
| 33 | 33 | ||
| 34 | 34 | ## Move panes and tabs | |
| 35 | 35 | ||
| @@ -29,7 +29,7 @@ Open it with `SSP` in the palette. It lists local sessions and, if you added hos | |||||
| 29 | 29 | ||||
| 30 | Recent-session history belongs to one client and is not saved. A new client starts with an empty history. A session that rings a bell also shows in the history with its bell, after the sessions this client visited, until any client sees it. | 30 | Recent-session history belongs to one client and is not saved. A new client starts with an empty history. A session that rings a bell also shows in the history with its bell, after the sessions this client visited, until any client sees it. | ||
| 31 | 31 | ||||
| 32 | - | A vev window only counts as seeing a bell while it has focus. With several vev windows open, a window on another workspace or behind another app never clears a bell, so the window you are using still shows it. This needs a terminal with focus reporting, such as kitty, foot, WezTerm, or Ghostty; other terminals keep treating every open window as seen. | 32 | + | A vev window only counts as seeing a bell while it has focus. With several vev windows open, a window on another workspace or behind another app never clears a bell, so the window you are using still shows it. This needs a terminal with focus reporting, such as kitty, foot, WezTerm, or Ghostty; other terminals keep treating every open window as seen. Focusing a vev window also resizes the session content to that window. |
| 33 | 33 | ||||
| 34 | ## Move panes and tabs | 34 | ## Move panes and tabs | ||
| 35 | 35 | ||||
M internal/usecase/daemon/terminal_focus.go +9 -2
| @@ -17,6 +17,9 @@ import ( | |||
| 17 | 17 | // bell, so a vev window left open on another workspace no longer clears it for | |
| 18 | 18 | // every other client. When that window gains focus again, the tab it shows is | |
| 19 | 19 | // acknowledged by its next paint. | |
| 20 | + | // | |
| 21 | + | // Geometry uses it too: a reported focus gain makes the attachment the latest | |
| 22 | + | // shared PTY geometry claimant, so session content follows the focused window. | |
| 20 | 23 | ||
| 21 | 24 | // terminalFocus returns the focus the client last reported. | |
| 22 | 25 | func (ac *attachedClient) terminalFocus() domain.TerminalFocus { | |
| @@ -32,8 +35,9 @@ func (ac *attachedClient) setTerminalFocus(focus domain.TerminalFocus) bool { | |||
| 32 | 35 | } | |
| 33 | 36 | ||
| 34 | 37 | // applyTerminalFocusForAttachment records a focus report. Gaining focus | |
| 35 | - | // repaints the attachment, whose paint acknowledges the visible tab's bell, | |
| 36 | - | // and the bars of every client, whose bells may clear with it. | |
| 38 | + | // claims shared session geometry and repaints the attachment, whose paint | |
| 39 | + | // acknowledges the visible tab's bell, and the bars of every client, whose | |
| 40 | + | // bells may clear with it. | |
| 37 | 41 | func (d *Daemon) applyTerminalFocusForAttachment(effect *attachmentEffect, message protocol.TerminalFocus) { | |
| 38 | 42 | if message.Validate() != nil || effect == nil || effect.sess == nil || effect.ac == nil || effect.ac.terminalFocus() == message.Focus { | |
| 39 | 43 | return | |
| @@ -47,6 +51,9 @@ func (d *Daemon) applyTerminalFocusForAttachment(effect *attachmentEffect, messa | |||
| 47 | 51 | // the focus lets any paint acknowledge. | |
| 48 | 52 | effect.sess.markVisibleAttention(effect.ac) | |
| 49 | 53 | effect.ac.setTerminalFocus(message.Focus) | |
| 54 | + | if message.Focus == domain.TerminalFocusFocused && effect.current() { | |
| 55 | + | effect.sess.geometry.reconcileAndInvalidate(d, effect.sess, effect.ac, "terminal_focus.go") | |
| 56 | + | } | |
| 50 | 57 | d.invalidateRender(effect.sess, effect.ac, false, "terminal_focus.go") | |
| 51 | 58 | } | |
| 52 | 59 | ||
| @@ -17,6 +17,9 @@ import ( | |||||
| 17 | // bell, so a vev window left open on another workspace no longer clears it for | 17 | // bell, so a vev window left open on another workspace no longer clears it for | ||
| 18 | // every other client. When that window gains focus again, the tab it shows is | 18 | // every other client. When that window gains focus again, the tab it shows is | ||
| 19 | // acknowledged by its next paint. | 19 | // acknowledged by its next paint. | ||
| 20 | + | // | |||
| 21 | + | // Geometry uses it too: a reported focus gain makes the attachment the latest | |||
| 22 | + | // shared PTY geometry claimant, so session content follows the focused window. | |||
| 20 | 23 | ||||
| 21 | // terminalFocus returns the focus the client last reported. | 24 | // terminalFocus returns the focus the client last reported. | ||
| 22 | func (ac *attachedClient) terminalFocus() domain.TerminalFocus { | 25 | func (ac *attachedClient) terminalFocus() domain.TerminalFocus { | ||
| @@ -32,8 +35,9 @@ func (ac *attachedClient) setTerminalFocus(focus domain.TerminalFocus) bool { | |||||
| 32 | } | 35 | } | ||
| 33 | 36 | ||||
| 34 | // applyTerminalFocusForAttachment records a focus report. Gaining focus | 37 | // applyTerminalFocusForAttachment records a focus report. Gaining focus | ||
| 35 | - | // repaints the attachment, whose paint acknowledges the visible tab's bell, | 38 | + | // claims shared session geometry and repaints the attachment, whose paint |
| 36 | - | // and the bars of every client, whose bells may clear with it. | 39 | + | // acknowledges the visible tab's bell, and the bars of every client, whose |
| 40 | + | // bells may clear with it. | |||
| 37 | func (d *Daemon) applyTerminalFocusForAttachment(effect *attachmentEffect, message protocol.TerminalFocus) { | 41 | func (d *Daemon) applyTerminalFocusForAttachment(effect *attachmentEffect, message protocol.TerminalFocus) { | ||
| 38 | if message.Validate() != nil || effect == nil || effect.sess == nil || effect.ac == nil || effect.ac.terminalFocus() == message.Focus { | 42 | if message.Validate() != nil || effect == nil || effect.sess == nil || effect.ac == nil || effect.ac.terminalFocus() == message.Focus { | ||
| 39 | return | 43 | return | ||
| @@ -47,6 +51,9 @@ func (d *Daemon) applyTerminalFocusForAttachment(effect *attachmentEffect, messa | |||||
| 47 | // the focus lets any paint acknowledge. | 51 | // the focus lets any paint acknowledge. | ||
| 48 | effect.sess.markVisibleAttention(effect.ac) | 52 | effect.sess.markVisibleAttention(effect.ac) | ||
| 49 | effect.ac.setTerminalFocus(message.Focus) | 53 | effect.ac.setTerminalFocus(message.Focus) | ||
| 54 | + | if message.Focus == domain.TerminalFocusFocused && effect.current() { | |||
| 55 | + | effect.sess.geometry.reconcileAndInvalidate(d, effect.sess, effect.ac, "terminal_focus.go") | |||
| 56 | + | } | |||
| 50 | d.invalidateRender(effect.sess, effect.ac, false, "terminal_focus.go") | 57 | d.invalidateRender(effect.sess, effect.ac, false, "terminal_focus.go") | ||
| 51 | } | 58 | } | ||
| 52 | 59 | ||||
M internal/usecase/daemon/terminal_focus_test.go +36 -0
| @@ -129,3 +129,39 @@ func TestFocusReportDrawsTheBellBeforeAcknowledging(t *testing.T) { | |||
| 129 | 129 | d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}) | |
| 130 | 130 | require.Empty(t, sends, "an unchanged focus does not repaint") | |
| 131 | 131 | } | |
| 132 | + | ||
| 133 | + | // TestFocusGainClaimsSharedGeometry pins that a client whose terminal window | |
| 134 | + | // gains focus becomes the shared geometry claimant, while losing focus leaves | |
| 135 | + | // the current claimant in place. | |
| 136 | + | func TestFocusGainClaimsSharedGeometry(t *testing.T) { | |
| 137 | + | tests := []struct { | |
| 138 | + | name string | |
| 139 | + | focus domain.TerminalFocus | |
| 140 | + | wantClaimed bool | |
| 141 | + | }{ | |
| 142 | + | {name: "focus gain claims geometry", focus: domain.TerminalFocusFocused, wantClaimed: true}, | |
| 143 | + | {name: "focus loss keeps claimant", focus: domain.TerminalFocusUnfocused, wantClaimed: false}, | |
| 144 | + | } | |
| 145 | + | for _, tt := range tests { | |
| 146 | + | t.Run(tt.name, func(t *testing.T) { | |
| 147 | + | d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 148 | + | first.setGeometry(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 149 | + | second := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}}) | |
| 150 | + | require.True(t, sess.geometry.reconcile(d, sess, second)) | |
| 151 | + | requireMultiplexGeometry(t, sess, second, domain.Size{Cols: 120, Rows: 38}) | |
| 152 | + | ||
| 153 | + | token := sess.captureAttachmentCapability(first, first.transport()) | |
| 154 | + | first.installTestAttachmentCapability(token) | |
| 155 | + | effect, admitted := first.beginAttachmentEffect(token) | |
| 156 | + | require.True(t, admitted) | |
| 157 | + | d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: tt.focus}) | |
| 158 | + | effect.End() | |
| 159 | + | ||
| 160 | + | if tt.wantClaimed { | |
| 161 | + | requireMultiplexGeometry(t, sess, first, domain.Size{Cols: 80, Rows: 22}) | |
| 162 | + | return | |
| 163 | + | } | |
| 164 | + | requireMultiplexGeometry(t, sess, second, domain.Size{Cols: 120, Rows: 38}) | |
| 165 | + | }) | |
| 166 | + | } | |
| 167 | + | } | |
| @@ -129,3 +129,39 @@ func TestFocusReportDrawsTheBellBeforeAcknowledging(t *testing.T) { | |||||
| 129 | d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}) | 129 | d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}) | ||
| 130 | require.Empty(t, sends, "an unchanged focus does not repaint") | 130 | require.Empty(t, sends, "an unchanged focus does not repaint") | ||
| 131 | } | 131 | } | ||
| 132 | + | ||||
| 133 | + | // TestFocusGainClaimsSharedGeometry pins that a client whose terminal window | |||
| 134 | + | // gains focus becomes the shared geometry claimant, while losing focus leaves | |||
| 135 | + | // the current claimant in place. | |||
| 136 | + | func TestFocusGainClaimsSharedGeometry(t *testing.T) { | |||
| 137 | + | tests := []struct { | |||
| 138 | + | name string | |||
| 139 | + | focus domain.TerminalFocus | |||
| 140 | + | wantClaimed bool | |||
| 141 | + | }{ | |||
| 142 | + | {name: "focus gain claims geometry", focus: domain.TerminalFocusFocused, wantClaimed: true}, | |||
| 143 | + | {name: "focus loss keeps claimant", focus: domain.TerminalFocusUnfocused, wantClaimed: false}, | |||
| 144 | + | } | |||
| 145 | + | for _, tt := range tests { | |||
| 146 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 147 | + | d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |||
| 148 | + | first.setGeometry(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}) | |||
| 149 | + | second := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}}) | |||
| 150 | + | require.True(t, sess.geometry.reconcile(d, sess, second)) | |||
| 151 | + | requireMultiplexGeometry(t, sess, second, domain.Size{Cols: 120, Rows: 38}) | |||
| 152 | + | ||||
| 153 | + | token := sess.captureAttachmentCapability(first, first.transport()) | |||
| 154 | + | first.installTestAttachmentCapability(token) | |||
| 155 | + | effect, admitted := first.beginAttachmentEffect(token) | |||
| 156 | + | require.True(t, admitted) | |||
| 157 | + | d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: tt.focus}) | |||
| 158 | + | effect.End() | |||
| 159 | + | ||||
| 160 | + | if tt.wantClaimed { | |||
| 161 | + | requireMultiplexGeometry(t, sess, first, domain.Size{Cols: 80, Rows: 22}) | |||
| 162 | + | return | |||
| 163 | + | } | |||
| 164 | + | requireMultiplexGeometry(t, sess, second, domain.Size{Cols: 120, Rows: 38}) | |||
| 165 | + | }) | |||
| 166 | + | } | |||
| 167 | + | } | |||