detached 27972fd

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
9292
9393 ### Terminal focus
9494
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.
9696
9797 ## Broker-owned hosts and daemon composition
9898
@@ -92,7 +92,7 @@ Attention remains a daemon fact. The broker publishes it from fresh observations
92 92
93 ### Terminal focus93 ### 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 composition97 ## 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
2929
3030 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.
3131
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.
3333
3434 ## Move panes and tabs
3535
@@ -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 tabs34 ## Move panes and tabs
35 35
M internal/usecase/daemon/terminal_focus.go +9 -2
@@ -17,6 +17,9 @@ import (
1717 // bell, so a vev window left open on another workspace no longer clears it for
1818 // every other client. When that window gains focus again, the tab it shows is
1919 // 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.
2023
2124 // terminalFocus returns the focus the client last reported.
2225 func (ac *attachedClient) terminalFocus() domain.TerminalFocus {
@@ -32,8 +35,9 @@ func (ac *attachedClient) setTerminalFocus(focus domain.TerminalFocus) bool {
3235 }
3336
3437 // 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.
3741 func (d *Daemon) applyTerminalFocusForAttachment(effect *attachmentEffect, message protocol.TerminalFocus) {
3842 if message.Validate() != nil || effect == nil || effect.sess == nil || effect.ac == nil || effect.ac.terminalFocus() == message.Focus {
3943 return
@@ -47,6 +51,9 @@ func (d *Daemon) applyTerminalFocusForAttachment(effect *attachmentEffect, messa
4751 // the focus lets any paint acknowledge.
4852 effect.sess.markVisibleAttention(effect.ac)
4953 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+ }
5057 d.invalidateRender(effect.sess, effect.ac, false, "terminal_focus.go")
5158 }
5259
@@ -17,6 +17,9 @@ import (
17 // bell, so a vev window left open on another workspace no longer clears it for17 // 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 is18 // 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 focus37 // 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 return43 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) {
129129 d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused})
130130 require.Empty(t, sends, "an unchanged focus does not repaint")
131131 }
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+}

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