detached 7b5f8cf

feat(focus): only a focused vev client acknowledges attention bells (#284)

* feat(focus): report terminal window focus and let only a seen client clear a bell

* fix(focus): declare terminal focus in Hello and draw a found bell before acknowledging it
commit
7b5f8cfe0e6f89f78f8ffcd2203d8b7333cd6ae9
author
bnema · · 2026-09-24 10:15 UTC
committer
brice · · 2026-09-26 08:37 UTC
parent
41d5105
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
35 files · +961 -133
M docs/architecture.md +4 -0
@@ -90,6 +90,10 @@ A terminal resize reaches the client as one coalesced invalidation owned by the
9090
9191 Attention remains a daemon fact. The broker publishes it from fresh observations but strips it from durable snapshots. The client's route ledger forwards attention order to the serving daemon for status-bar bells and cross-daemon jump-to-attention; the picker displays session and tab bells. The serving daemon uses that same route snapshot to display remote palette destinations, without monitoring other daemons.
9292
93+### Terminal focus
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.
96+
9397 ## Broker-owned hosts and daemon composition
9498
9599 `vev _broker-ready --require local-authority|local-catalogue` is an internal,
@@ -90,6 +90,10 @@ A terminal resize reaches the client as one coalesced invalidation owned by the
90 90
91 Attention remains a daemon fact. The broker publishes it from fresh observations but strips it from durable snapshots. The client's route ledger forwards attention order to the serving daemon for status-bar bells and cross-daemon jump-to-attention; the picker displays session and tab bells. The serving daemon uses that same route snapshot to display remote palette destinations, without monitoring other daemons.91 Attention remains a daemon fact. The broker publishes it from fresh observations but strips it from durable snapshots. The client's route ledger forwards attention order to the serving daemon for status-bar bells and cross-daemon jump-to-attention; the picker displays session and tab bells. The serving daemon uses that same route snapshot to display remote palette destinations, without monitoring other daemons.
92 92
93+### Terminal focus
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.
96+
93 ## Broker-owned hosts and daemon composition97 ## Broker-owned hosts and daemon composition
94 98
95 `vev _broker-ready --require local-authority|local-catalogue` is an internal,99 `vev _broker-ready --require local-authority|local-catalogue` is an internal,
M docs/protocol.md +1 -1
@@ -12,7 +12,7 @@ wrong-direction frame before any mutation (`sessionwire.ErrWrongDirection`
1212
1313 Client → server: `Hello`, `Input`, `Resize`, `Detach`, `Ping`, `List`,
1414 `Kill`, `Theme`, `Ack`, `ImagePush`, `ClientNotice`, `CommandRequest`,
15-`OutputResetRequest`, `SelectTab`, `RemotePreviewWatch`,
15+`OutputResetRequest`, `SelectTab`, `TerminalFocus`, `RemotePreviewWatch`,
1616 `RouteAttentionSubscription`, `SamePeerSwitchRequest`,
1717 `RecentRouteSnapshot`, `RouteNavigationFailure`,
1818 `SessionCreationFailure`, `UIFence`, `NavigationInventoryRequest`,
@@ -12,7 +12,7 @@ wrong-direction frame before any mutation (`sessionwire.ErrWrongDirection`
12 12
13 Client → server: `Hello`, `Input`, `Resize`, `Detach`, `Ping`, `List`,13 Client → server: `Hello`, `Input`, `Resize`, `Detach`, `Ping`, `List`,
14 `Kill`, `Theme`, `Ack`, `ImagePush`, `ClientNotice`, `CommandRequest`,14 `Kill`, `Theme`, `Ack`, `ImagePush`, `ClientNotice`, `CommandRequest`,
15-`OutputResetRequest`, `SelectTab`, `RemotePreviewWatch`,15+`OutputResetRequest`, `SelectTab`, `TerminalFocus`, `RemotePreviewWatch`,
16 `RouteAttentionSubscription`, `SamePeerSwitchRequest`,16 `RouteAttentionSubscription`, `SamePeerSwitchRequest`,
17 `RecentRouteSnapshot`, `RouteNavigationFailure`,17 `RecentRouteSnapshot`, `RouteNavigationFailure`,
18 `SessionCreationFailure`, `UIFence`, `NavigationInventoryRequest`,18 `SessionCreationFailure`, `UIFence`, `NavigationInventoryRequest`,
M docs/session-navigation.md +2 -0
@@ -29,6 +29,8 @@ 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.
33+
3234 ## Move panes and tabs
3335
3436 - `MFP` moves the focused pane to another tab.
@@ -29,6 +29,8 @@ 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.
33+
32 ## Move panes and tabs34 ## Move panes and tabs
33 35
34 - `MFP` moves the focused pane to another tab.36 - `MFP` moves the focused pane to another tab.
M internal/adapters/sessionwire/proto_convert_misc.go +20 -0
@@ -1,6 +1,8 @@
11 package sessionwire
22
33 import (
4+ "math"
5+
46 "github.com/bnema/vev/internal/domain"
57 "github.com/bnema/vev/internal/protocol"
68 "github.com/bnema/vev/internal/protocol/wire"
@@ -332,6 +334,24 @@ func selectTabToWire(message protocol.SelectTab) (*wire.SelectTab, error) {
332334 return &wire.SelectTab{TabId: string(message.TabID)}, nil
333335 }
334336
337+func terminalFocusToWire(message protocol.TerminalFocus) (*wire.TerminalFocus, error) {
338+ if err := message.Validate(); err != nil {
339+ return nil, err
340+ }
341+ return &wire.TerminalFocus{Focus: uint32(message.Focus)}, nil
342+}
343+
344+func terminalFocusFromWire(message *wire.TerminalFocus) (protocol.TerminalFocus, error) {
345+ if message == nil || message.GetFocus() > math.MaxUint8 {
346+ return protocol.TerminalFocus{}, errProtoConvertRange
347+ }
348+ focus := protocol.TerminalFocus{Focus: domain.TerminalFocus(message.GetFocus())}
349+ if err := focus.Validate(); err != nil {
350+ return protocol.TerminalFocus{}, err
351+ }
352+ return focus, nil
353+}
354+
335355 func selectTabFromWire(message *wire.SelectTab) (protocol.SelectTab, error) {
336356 if message == nil {
337357 return protocol.SelectTab{}, errProtoConvertRange
@@ -1,6 +1,8 @@
1 package sessionwire1 package sessionwire
2 2
3 import (3 import (
4+ "math"
5+
4 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/protocol"7 "github.com/bnema/vev/internal/protocol"
6 "github.com/bnema/vev/internal/protocol/wire"8 "github.com/bnema/vev/internal/protocol/wire"
@@ -332,6 +334,24 @@ func selectTabToWire(message protocol.SelectTab) (*wire.SelectTab, error) {
332 return &wire.SelectTab{TabId: string(message.TabID)}, nil334 return &wire.SelectTab{TabId: string(message.TabID)}, nil
333 }335 }
334 336
337+func terminalFocusToWire(message protocol.TerminalFocus) (*wire.TerminalFocus, error) {
338+ if err := message.Validate(); err != nil {
339+ return nil, err
340+ }
341+ return &wire.TerminalFocus{Focus: uint32(message.Focus)}, nil
342+}
343+
344+func terminalFocusFromWire(message *wire.TerminalFocus) (protocol.TerminalFocus, error) {
345+ if message == nil || message.GetFocus() > math.MaxUint8 {
346+ return protocol.TerminalFocus{}, errProtoConvertRange
347+ }
348+ focus := protocol.TerminalFocus{Focus: domain.TerminalFocus(message.GetFocus())}
349+ if err := focus.Validate(); err != nil {
350+ return protocol.TerminalFocus{}, err
351+ }
352+ return focus, nil
353+}
354+
335 func selectTabFromWire(message *wire.SelectTab) (protocol.SelectTab, error) {355 func selectTabFromWire(message *wire.SelectTab) (protocol.SelectTab, error) {
336 if message == nil {356 if message == nil {
337 return protocol.SelectTab{}, errProtoConvertRange357 return protocol.SelectTab{}, errProtoConvertRange
M internal/adapters/sessionwire/proto_convert_range_test.go +20 -0
@@ -60,6 +60,26 @@ func TestNarrowingConversionsRejectOverflow(t *testing.T) {
6060 },
6161 wantErr: errProtoConvertRange,
6262 },
63+ {
64+ name: "hello terminal focus range",
65+ run: func(t *testing.T) error {
66+ hello := testWireHello(t)
67+ hello.TerminalFocus = 256
68+ _, err := helloFromWire(hello)
69+ return err
70+ },
71+ wantErr: errProtoConvertRange,
72+ },
73+ {
74+ name: "hello terminal focus taxonomy",
75+ run: func(t *testing.T) error {
76+ hello := testWireHello(t)
77+ hello.TerminalFocus = 3
78+ _, err := helloFromWire(hello)
79+ return err
80+ },
81+ wantErr: protocol.ErrInvalidHello,
82+ },
6383 {
6484 name: "hello geometry",
6585 run: func(t *testing.T) error {
@@ -60,6 +60,26 @@ func TestNarrowingConversionsRejectOverflow(t *testing.T) {
60 },60 },
61 wantErr: errProtoConvertRange,61 wantErr: errProtoConvertRange,
62 },62 },
63+ {
64+ name: "hello terminal focus range",
65+ run: func(t *testing.T) error {
66+ hello := testWireHello(t)
67+ hello.TerminalFocus = 256
68+ _, err := helloFromWire(hello)
69+ return err
70+ },
71+ wantErr: errProtoConvertRange,
72+ },
73+ {
74+ name: "hello terminal focus taxonomy",
75+ run: func(t *testing.T) error {
76+ hello := testWireHello(t)
77+ hello.TerminalFocus = 3
78+ _, err := helloFromWire(hello)
79+ return err
80+ },
81+ wantErr: protocol.ErrInvalidHello,
82+ },
63 {83 {
64 name: "hello geometry",84 name: "hello geometry",
65 run: func(t *testing.T) error {85 run: func(t *testing.T) error {
M internal/adapters/sessionwire/proto_convert_session.go +5 -0
@@ -52,6 +52,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) {
5252 Remote: message.Remote,
5353 KittyDirectGraphics: message.KittyDirectGraphics,
5454 KittyKeyboard: message.KittyKeyboard,
55+ TerminalFocus: uint32(message.TerminalFocus),
5556 }, nil
5657 }
5758
@@ -135,6 +136,10 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) {
135136 hello.Remote = message.GetRemote()
136137 hello.KittyDirectGraphics = message.GetKittyDirectGraphics()
137138 hello.KittyKeyboard = message.GetKittyKeyboard()
139+ hello.TerminalFocus, err = enum8[domain.TerminalFocus](message.GetTerminalFocus())
140+ if err != nil {
141+ return protocol.Hello{}, err
142+ }
138143 if err := protocol.ValidateHello(hello); err != nil {
139144 return protocol.Hello{}, err
140145 }
@@ -52,6 +52,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) {
52 Remote: message.Remote,52 Remote: message.Remote,
53 KittyDirectGraphics: message.KittyDirectGraphics,53 KittyDirectGraphics: message.KittyDirectGraphics,
54 KittyKeyboard: message.KittyKeyboard,54 KittyKeyboard: message.KittyKeyboard,
55+ TerminalFocus: uint32(message.TerminalFocus),
55 }, nil56 }, nil
56 }57 }
57 58
@@ -135,6 +136,10 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) {
135 hello.Remote = message.GetRemote()136 hello.Remote = message.GetRemote()
136 hello.KittyDirectGraphics = message.GetKittyDirectGraphics()137 hello.KittyDirectGraphics = message.GetKittyDirectGraphics()
137 hello.KittyKeyboard = message.GetKittyKeyboard()138 hello.KittyKeyboard = message.GetKittyKeyboard()
139+ hello.TerminalFocus, err = enum8[domain.TerminalFocus](message.GetTerminalFocus())
140+ if err != nil {
141+ return protocol.Hello{}, err
142+ }
138 if err := protocol.ValidateHello(hello); err != nil {143 if err := protocol.ValidateHello(hello); err != nil {
139 return protocol.Hello{}, err144 return protocol.Hello{}, err
140 }145 }
M internal/adapters/sessionwire/proto_convert_test.go +50 -0
@@ -292,6 +292,56 @@ func TestProtoSelectTabWire(t *testing.T) {
292292 require.Error(t, err, "an empty tab identity is never decoded")
293293 }
294294
295+// TestProtoTerminalFocusWire pins the TerminalFocus payload byte for byte and
296+// refuses unknown focus, a truncated prefix, and trailing garbage.
297+func TestProtoTerminalFocusWire(t *testing.T) {
298+ tests := []struct {
299+ name string
300+ focus domain.TerminalFocus
301+ want []byte
302+ }{
303+ // field 33 (length-delimited) -> TerminalFocus{field 1 = focus}.
304+ {name: "focused", focus: domain.TerminalFocusFocused, want: []byte{0x8A, 0x02, 0x02, 0x08, 0x01}},
305+ {name: "unfocused", focus: domain.TerminalFocusUnfocused, want: []byte{0x8A, 0x02, 0x02, 0x08, 0x02}},
306+ }
307+ for _, tt := range tests {
308+ t.Run(tt.name, func(t *testing.T) {
309+ message := protocol.TerminalFocus{Focus: tt.focus}
310+ envelope, err := encodeProtoClient(message)
311+ require.NoError(t, err)
312+ raw, err := proto.Marshal(envelope)
313+ require.NoError(t, err)
314+ require.Equal(t, tt.want, raw)
315+
316+ require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix")
317+ require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage")
318+ require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
319+ decoded := &wire.ClientEnvelope{}
320+ require.NoError(t, proto.Unmarshal(raw, decoded))
321+ got, err := decodeProtoClient(decoded)
322+ require.NoError(t, err)
323+ require.Equal(t, message, got)
324+ })
325+ }
326+
327+ invalid := []struct {
328+ name string
329+ focus uint32
330+ }{
331+ {name: "unknown is never reported", focus: 0},
332+ {name: "out of taxonomy", focus: 3},
333+ {name: "out of range", focus: 256},
334+ }
335+ for _, tt := range invalid {
336+ t.Run(tt.name, func(t *testing.T) {
337+ _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_TerminalFocus{TerminalFocus: &wire.TerminalFocus{Focus: tt.focus}}})
338+ require.Error(t, err)
339+ })
340+ }
341+ _, err := encodeProtoClient(protocol.TerminalFocus{})
342+ require.Error(t, err, "unknown focus is never encoded")
343+}
344+
295345 func protoMessageName(message any) string {
296346 return protoShortName(message)
297347 }
@@ -292,6 +292,56 @@ func TestProtoSelectTabWire(t *testing.T) {
292 require.Error(t, err, "an empty tab identity is never decoded")292 require.Error(t, err, "an empty tab identity is never decoded")
293 }293 }
294 294
295+// TestProtoTerminalFocusWire pins the TerminalFocus payload byte for byte and
296+// refuses unknown focus, a truncated prefix, and trailing garbage.
297+func TestProtoTerminalFocusWire(t *testing.T) {
298+ tests := []struct {
299+ name string
300+ focus domain.TerminalFocus
301+ want []byte
302+ }{
303+ // field 33 (length-delimited) -> TerminalFocus{field 1 = focus}.
304+ {name: "focused", focus: domain.TerminalFocusFocused, want: []byte{0x8A, 0x02, 0x02, 0x08, 0x01}},
305+ {name: "unfocused", focus: domain.TerminalFocusUnfocused, want: []byte{0x8A, 0x02, 0x02, 0x08, 0x02}},
306+ }
307+ for _, tt := range tests {
308+ t.Run(tt.name, func(t *testing.T) {
309+ message := protocol.TerminalFocus{Focus: tt.focus}
310+ envelope, err := encodeProtoClient(message)
311+ require.NoError(t, err)
312+ raw, err := proto.Marshal(envelope)
313+ require.NoError(t, err)
314+ require.Equal(t, tt.want, raw)
315+
316+ require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix")
317+ require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage")
318+ require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
319+ decoded := &wire.ClientEnvelope{}
320+ require.NoError(t, proto.Unmarshal(raw, decoded))
321+ got, err := decodeProtoClient(decoded)
322+ require.NoError(t, err)
323+ require.Equal(t, message, got)
324+ })
325+ }
326+
327+ invalid := []struct {
328+ name string
329+ focus uint32
330+ }{
331+ {name: "unknown is never reported", focus: 0},
332+ {name: "out of taxonomy", focus: 3},
333+ {name: "out of range", focus: 256},
334+ }
335+ for _, tt := range invalid {
336+ t.Run(tt.name, func(t *testing.T) {
337+ _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_TerminalFocus{TerminalFocus: &wire.TerminalFocus{Focus: tt.focus}}})
338+ require.Error(t, err)
339+ })
340+ }
341+ _, err := encodeProtoClient(protocol.TerminalFocus{})
342+ require.Error(t, err, "unknown focus is never encoded")
343+}
344+
295 func protoMessageName(message any) string {345 func protoMessageName(message any) string {
296 return protoShortName(message)346 return protoShortName(message)
297 }347 }
M internal/adapters/sessionwire/proto_envelope.go +13 -0
@@ -159,6 +159,17 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er
159159 return nil, ErrInvalidMessage
160160 }
161161 return encodeProtoClient(*m)
162+ case protocol.TerminalFocus:
163+ converted, err := terminalFocusToWire(m)
164+ if err != nil {
165+ return nil, err
166+ }
167+ return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_TerminalFocus{TerminalFocus: converted}}, nil
168+ case *protocol.TerminalFocus:
169+ if m == nil {
170+ return nil, ErrInvalidMessage
171+ }
172+ return encodeProtoClient(*m)
162173 case protocol.UIFence:
163174 return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil
164175 case *protocol.UIFence:
@@ -643,6 +654,8 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e
643654 return uiFenceFromWire(payload.UiFence)
644655 case *wire.ClientEnvelope_SelectTab:
645656 return selectTabFromWire(payload.SelectTab)
657+ case *wire.ClientEnvelope_TerminalFocus:
658+ return terminalFocusFromWire(payload.TerminalFocus)
646659 case *wire.ClientEnvelope_RemotePreviewWatch:
647660 return remotePreviewWatchFromWire(payload.RemotePreviewWatch)
648661 case *wire.ClientEnvelope_RouteAttentionSubscription:
@@ -159,6 +159,17 @@ func encodeProtoClient(message protocol.ClientMessage) (*wire.ClientEnvelope, er
159 return nil, ErrInvalidMessage159 return nil, ErrInvalidMessage
160 }160 }
161 return encodeProtoClient(*m)161 return encodeProtoClient(*m)
162+ case protocol.TerminalFocus:
163+ converted, err := terminalFocusToWire(m)
164+ if err != nil {
165+ return nil, err
166+ }
167+ return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_TerminalFocus{TerminalFocus: converted}}, nil
168+ case *protocol.TerminalFocus:
169+ if m == nil {
170+ return nil, ErrInvalidMessage
171+ }
172+ return encodeProtoClient(*m)
162 case protocol.UIFence:173 case protocol.UIFence:
163 return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil174 return &wire.ClientEnvelope{Payload: &wire.ClientEnvelope_UiFence{UiFence: uiFenceToWire(m)}}, nil
164 case *protocol.UIFence:175 case *protocol.UIFence:
@@ -643,6 +654,8 @@ func decodeProtoClient(envelope *wire.ClientEnvelope) (protocol.ClientMessage, e
643 return uiFenceFromWire(payload.UiFence)654 return uiFenceFromWire(payload.UiFence)
644 case *wire.ClientEnvelope_SelectTab:655 case *wire.ClientEnvelope_SelectTab:
645 return selectTabFromWire(payload.SelectTab)656 return selectTabFromWire(payload.SelectTab)
657+ case *wire.ClientEnvelope_TerminalFocus:
658+ return terminalFocusFromWire(payload.TerminalFocus)
646 case *wire.ClientEnvelope_RemotePreviewWatch:659 case *wire.ClientEnvelope_RemotePreviewWatch:
647 return remotePreviewWatchFromWire(payload.RemotePreviewWatch)660 return remotePreviewWatchFromWire(payload.RemotePreviewWatch)
648 case *wire.ClientEnvelope_RouteAttentionSubscription:661 case *wire.ClientEnvelope_RouteAttentionSubscription:
M internal/adapters/term/pty_integration_test.go +1 -1
@@ -92,7 +92,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
9292
9393 select {
9494 case got := <-captured:
95- want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
95+ want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit
9696 if string(got) != want {
9797 t.Fatalf("captured escapes = %q, want %q", got, want)
9898 }
@@ -92,7 +92,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
92 92
93 select {93 select {
94 case got := <-captured:94 case got := <-captured:
95- want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit95+ want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit
96 if string(got) != want {96 if string(got) != want {
97 t.Fatalf("captured escapes = %q, want %q", got, want)97 t.Fatalf("captured escapes = %q, want %q", got, want)
98 }98 }
M internal/adapters/term/terminal_test.go +4 -4
@@ -62,7 +62,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
6262 _ = outW.Close()
6363 <-done
6464
65- want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
65+ want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit
6666 if got := captured.String(); got != want {
6767 t.Fatalf("captured escapes = %q, want %q", got, want)
6868 }
@@ -179,9 +179,9 @@ func TestTerminal_WriteFailure_AttemptsVisualResetOnFd(t *testing.T) {
179179 // alt screen: a partial emission that got as far as cursorHide/mouseEnable
180180 // would otherwise leave the cursor hidden and mouse reporting on.
181181 const wantCleanup = cursorShow + cursorStyleDefault + mouseDisable +
182- bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
182+ bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit
183183 const enterSequence = altScreenEnter + autowrapDisable + cursorHide +
184- mouseEnable + bracketedPasteEnable + colorSchemeEnable
184+ mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable
185185
186186 for _, tc := range []struct {
187187 name string
@@ -280,7 +280,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
280280
281281 // Alt-screen/cursor escapes must appear exactly once for enter and exits
282282 // exactly once, regardless of how many times EnterRaw/restore were called.
283- want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
283+ want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit
284284 if got := captured.String(); got != want {
285285 t.Fatalf("captured escapes = %q, want %q", got, want)
286286 }
@@ -62,7 +62,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
62 _ = outW.Close()62 _ = outW.Close()
63 <-done63 <-done
64 64
65- want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit65+ want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit
66 if got := captured.String(); got != want {66 if got := captured.String(); got != want {
67 t.Fatalf("captured escapes = %q, want %q", got, want)67 t.Fatalf("captured escapes = %q, want %q", got, want)
68 }68 }
@@ -179,9 +179,9 @@ func TestTerminal_WriteFailure_AttemptsVisualResetOnFd(t *testing.T) {
179 // alt screen: a partial emission that got as far as cursorHide/mouseEnable179 // alt screen: a partial emission that got as far as cursorHide/mouseEnable
180 // would otherwise leave the cursor hidden and mouse reporting on.180 // would otherwise leave the cursor hidden and mouse reporting on.
181 const wantCleanup = cursorShow + cursorStyleDefault + mouseDisable +181 const wantCleanup = cursorShow + cursorStyleDefault + mouseDisable +
182- bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit182+ bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit
183 const enterSequence = altScreenEnter + autowrapDisable + cursorHide +183 const enterSequence = altScreenEnter + autowrapDisable + cursorHide +
184- mouseEnable + bracketedPasteEnable + colorSchemeEnable184+ mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable
185 185
186 for _, tc := range []struct {186 for _, tc := range []struct {
187 name string187 name string
@@ -280,7 +280,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
280 280
281 // Alt-screen/cursor escapes must appear exactly once for enter and exits281 // Alt-screen/cursor escapes must appear exactly once for enter and exits
282 // exactly once, regardless of how many times EnterRaw/restore were called.282 // exactly once, regardless of how many times EnterRaw/restore were called.
283- want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit283+ want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit
284 if got := captured.String(); got != want {284 if got := captured.String(); got != want {
285 t.Fatalf("captured escapes = %q, want %q", got, want)285 t.Fatalf("captured escapes = %q, want %q", got, want)
286 }286 }
M internal/adapters/term/visual.go +4 -2
@@ -14,15 +14,17 @@ const (
1414 bracketedPasteDisable = "\x1b[?2004l"
1515 colorSchemeEnable = "\x1b[?2031h"
1616 colorSchemeDisable = "\x1b[?2031l"
17+ focusReportEnable = "\x1b[?1004h"
18+ focusReportDisable = "\x1b[?1004l"
1719 autowrapDisable = "\x1b[?7l"
1820 autowrapEnable = "\x1b[?7h"
1921 kittyKeyboardPop = "\x1b[<u"
2022 )
2123
22-const visualEnter = altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable
24+const visualEnter = altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable
2325
2426 const visualRestore = cursorShow + cursorStyleDefault + mouseDisable +
25- bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit
27+ bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit
2628
2729 // VisualEnterSequence returns a fresh copy of the visual initialization used
2830 // after acquiring a terminal. It intentionally excludes OS raw-mode setup.
@@ -14,15 +14,17 @@ const (
14 bracketedPasteDisable = "\x1b[?2004l"14 bracketedPasteDisable = "\x1b[?2004l"
15 colorSchemeEnable = "\x1b[?2031h"15 colorSchemeEnable = "\x1b[?2031h"
16 colorSchemeDisable = "\x1b[?2031l"16 colorSchemeDisable = "\x1b[?2031l"
17+ focusReportEnable = "\x1b[?1004h"
18+ focusReportDisable = "\x1b[?1004l"
17 autowrapDisable = "\x1b[?7l"19 autowrapDisable = "\x1b[?7l"
18 autowrapEnable = "\x1b[?7h"20 autowrapEnable = "\x1b[?7h"
19 kittyKeyboardPop = "\x1b[<u"21 kittyKeyboardPop = "\x1b[<u"
20 )22 )
21 23
22-const visualEnter = altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable24+const visualEnter = altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + focusReportEnable
23 25
24 const visualRestore = cursorShow + cursorStyleDefault + mouseDisable +26 const visualRestore = cursorShow + cursorStyleDefault + mouseDisable +
25- bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit27+ bracketedPasteDisable + colorSchemeDisable + focusReportDisable + autowrapEnable + altScreenExit
26 28
27 // VisualEnterSequence returns a fresh copy of the visual initialization used29 // VisualEnterSequence returns a fresh copy of the visual initialization used
28 // after acquiring a terminal. It intentionally excludes OS raw-mode setup.30 // after acquiring a terminal. It intentionally excludes OS raw-mode setup.
M internal/adapters/webterm/input.go +2 -1
@@ -90,7 +90,8 @@ func (t *Terminal) Handle(ctx context.Context, event browser.Event) error {
9090 }
9191 data = sb.String()
9292 case browser.EventFocus:
93- // Focus reporting is not negotiated by the current client terminal contract.
93+ // The browser page stays out of focus reporting: web clients keep
94+ // unknown focus and count as possibly seen.
9495 return nil
9596 }
9697 return t.Send(ctx, []byte(data))
@@ -90,7 +90,8 @@ func (t *Terminal) Handle(ctx context.Context, event browser.Event) error {
90 }90 }
91 data = sb.String()91 data = sb.String()
92 case browser.EventFocus:92 case browser.EventFocus:
93- // Focus reporting is not negotiated by the current client terminal contract.93+ // The browser page stays out of focus reporting: web clients keep
94+ // unknown focus and count as possibly seen.
94 return nil95 return nil
95 }96 }
96 return t.Send(ctx, []byte(data))97 return t.Send(ctx, []byte(data))
A internal/domain/terminal_focus.go +36 -0
@@ -0,0 +1,36 @@
1+package domain
2+
3+// TerminalFocus is whether the user's terminal window shows this client.
4+//
5+// A client learns it from terminal focus reporting (DEC mode 1004). Until the
6+// terminal reports, or when it never does, focus is unknown, and callers must
7+// treat an unknown client as possibly seen, exactly as before focus existed.
8+type TerminalFocus uint8
9+
10+const (
11+ TerminalFocusUnknown TerminalFocus = iota
12+ TerminalFocusFocused
13+ TerminalFocusUnfocused
14+)
15+
16+// Valid reports whether f is one of the closed focus states.
17+func (f TerminalFocus) Valid() bool {
18+ return f <= TerminalFocusUnfocused
19+}
20+
21+// MaySee reports whether a user may be looking at this client. Only a
22+// reported loss of focus rules that out.
23+func (f TerminalFocus) MaySee() bool {
24+ return f != TerminalFocusUnfocused
25+}
26+
27+func (f TerminalFocus) String() string {
28+ switch f {
29+ case TerminalFocusFocused:
30+ return "focused"
31+ case TerminalFocusUnfocused:
32+ return "unfocused"
33+ default:
34+ return "unknown"
35+ }
36+}
@@ -0,0 +1,36 @@
1+package domain
2+
3+// TerminalFocus is whether the user's terminal window shows this client.
4+//
5+// A client learns it from terminal focus reporting (DEC mode 1004). Until the
6+// terminal reports, or when it never does, focus is unknown, and callers must
7+// treat an unknown client as possibly seen, exactly as before focus existed.
8+type TerminalFocus uint8
9+
10+const (
11+ TerminalFocusUnknown TerminalFocus = iota
12+ TerminalFocusFocused
13+ TerminalFocusUnfocused
14+)
15+
16+// Valid reports whether f is one of the closed focus states.
17+func (f TerminalFocus) Valid() bool {
18+ return f <= TerminalFocusUnfocused
19+}
20+
21+// MaySee reports whether a user may be looking at this client. Only a
22+// reported loss of focus rules that out.
23+func (f TerminalFocus) MaySee() bool {
24+ return f != TerminalFocusUnfocused
25+}
26+
27+func (f TerminalFocus) String() string {
28+ switch f {
29+ case TerminalFocusFocused:
30+ return "focused"
31+ case TerminalFocusUnfocused:
32+ return "unfocused"
33+ default:
34+ return "unknown"
35+ }
36+}
M internal/protocol/messages.go +1 -0
@@ -20,6 +20,7 @@ func (ClientNotice) clientMessage() {}
2020 func (CommandRequest) clientMessage() {}
2121 func (OutputResetRequest) clientMessage() {}
2222 func (SelectTab) clientMessage() {}
23+func (TerminalFocus) clientMessage() {}
2324 func (RemotePreviewWatch) clientMessage() {}
2425 func (RouteAttentionSubscription) clientMessage() {}
2526 func (SamePeerSwitchRequest) clientMessage() {}
@@ -20,6 +20,7 @@ func (ClientNotice) clientMessage() {}
20 func (CommandRequest) clientMessage() {}20 func (CommandRequest) clientMessage() {}
21 func (OutputResetRequest) clientMessage() {}21 func (OutputResetRequest) clientMessage() {}
22 func (SelectTab) clientMessage() {}22 func (SelectTab) clientMessage() {}
23+func (TerminalFocus) clientMessage() {}
23 func (RemotePreviewWatch) clientMessage() {}24 func (RemotePreviewWatch) clientMessage() {}
24 func (RouteAttentionSubscription) clientMessage() {}25 func (RouteAttentionSubscription) clientMessage() {}
25 func (SamePeerSwitchRequest) clientMessage() {}26 func (SamePeerSwitchRequest) clientMessage() {}
M internal/protocol/session.go +21 -2
@@ -16,7 +16,7 @@ import (
1616 // daemonmux physical preamble conversation (multiplex.proto), whose
1717 // negotiated ceilings and accepted daemon binding ride the same wire
1818 // version as the session and broker conversations.
19-const Version uint16 = 62
19+const Version uint16 = 63
2020
2121 // HandshakeTimeout bounds every transport handshake from connect through the
2222 // first committed publication. It excludes the preceding client-local
@@ -51,6 +51,7 @@ var (
5151 ErrInvalidNavigation = errors.New("ports: invalid navigation")
5252 ErrInvalidEnvironmentPolicy = errors.New("ports: invalid environment policy")
5353 ErrInvalidClientNotice = errors.New("ports: invalid client notice")
54+ ErrInvalidTerminalFocus = errors.New("ports: invalid terminal focus")
5455 ErrInvalidDetached = errors.New("ports: invalid detached reason")
5556 )
5657
@@ -124,6 +125,9 @@ type Hello struct {
124125 EnvironmentPolicy EnvironmentPolicy
125126 NavigationCapabilities NavigationCapabilities
126127 Remote bool
128+ // TerminalFocus is the client's terminal window focus when it attaches,
129+ // so the attachment's first paint already knows whether it may be seen.
130+ TerminalFocus domain.TerminalFocus
127131 }
128132
129133 func (h Hello) Geometry() domain.Geometry {
@@ -364,6 +368,21 @@ func (m SelectTab) Validate() error {
364368 return domain.ValidateTabStableID(m.TabID)
365369 }
366370
371+// TerminalFocus reports that the client's terminal window gained or lost
372+// focus. A client sends it only after its terminal reports focus, so an
373+// attachment that never sends it keeps unknown focus.
374+type TerminalFocus struct {
375+ Focus domain.TerminalFocus
376+}
377+
378+// Validate accepts only a reported state: unknown is the absence of a report.
379+func (m TerminalFocus) Validate() error {
380+ if m.Focus != domain.TerminalFocusFocused && m.Focus != domain.TerminalFocusUnfocused {
381+ return ErrInvalidTerminalFocus
382+ }
383+ return nil
384+}
385+
367386 func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
368387 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
369388 }
@@ -414,7 +433,7 @@ func ValidateHello(h Hello) error {
414433 if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {
415434 return fmt.Errorf("%w: geometry", ErrInvalidHello)
416435 }
417- if !validEnvironmentPolicy(h.EnvironmentPolicy) {
436+ if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() {
418437 return ErrInvalidHello
419438 }
420439 if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil {
@@ -16,7 +16,7 @@ import (
16 // daemonmux physical preamble conversation (multiplex.proto), whose16 // daemonmux physical preamble conversation (multiplex.proto), whose
17 // negotiated ceilings and accepted daemon binding ride the same wire17 // negotiated ceilings and accepted daemon binding ride the same wire
18 // version as the session and broker conversations.18 // version as the session and broker conversations.
19-const Version uint16 = 6219+const Version uint16 = 63
20 20
21 // HandshakeTimeout bounds every transport handshake from connect through the21 // HandshakeTimeout bounds every transport handshake from connect through the
22 // first committed publication. It excludes the preceding client-local22 // first committed publication. It excludes the preceding client-local
@@ -51,6 +51,7 @@ var (
51 ErrInvalidNavigation = errors.New("ports: invalid navigation")51 ErrInvalidNavigation = errors.New("ports: invalid navigation")
52 ErrInvalidEnvironmentPolicy = errors.New("ports: invalid environment policy")52 ErrInvalidEnvironmentPolicy = errors.New("ports: invalid environment policy")
53 ErrInvalidClientNotice = errors.New("ports: invalid client notice")53 ErrInvalidClientNotice = errors.New("ports: invalid client notice")
54+ ErrInvalidTerminalFocus = errors.New("ports: invalid terminal focus")
54 ErrInvalidDetached = errors.New("ports: invalid detached reason")55 ErrInvalidDetached = errors.New("ports: invalid detached reason")
55 )56 )
56 57
@@ -124,6 +125,9 @@ type Hello struct {
124 EnvironmentPolicy EnvironmentPolicy125 EnvironmentPolicy EnvironmentPolicy
125 NavigationCapabilities NavigationCapabilities126 NavigationCapabilities NavigationCapabilities
126 Remote bool127 Remote bool
128+ // TerminalFocus is the client's terminal window focus when it attaches,
129+ // so the attachment's first paint already knows whether it may be seen.
130+ TerminalFocus domain.TerminalFocus
127 }131 }
128 132
129 func (h Hello) Geometry() domain.Geometry {133 func (h Hello) Geometry() domain.Geometry {
@@ -364,6 +368,21 @@ func (m SelectTab) Validate() error {
364 return domain.ValidateTabStableID(m.TabID)368 return domain.ValidateTabStableID(m.TabID)
365 }369 }
366 370
371+// TerminalFocus reports that the client's terminal window gained or lost
372+// focus. A client sends it only after its terminal reports focus, so an
373+// attachment that never sends it keeps unknown focus.
374+type TerminalFocus struct {
375+ Focus domain.TerminalFocus
376+}
377+
378+// Validate accepts only a reported state: unknown is the absence of a report.
379+func (m TerminalFocus) Validate() error {
380+ if m.Focus != domain.TerminalFocusFocused && m.Focus != domain.TerminalFocusUnfocused {
381+ return ErrInvalidTerminalFocus
382+ }
383+ return nil
384+}
385+
367 func validEnvironmentPolicy(policy EnvironmentPolicy) bool {386 func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
368 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned387 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
369 }388 }
@@ -414,7 +433,7 @@ func ValidateHello(h Hello) error {
414 if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {433 if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {
415 return fmt.Errorf("%w: geometry", ErrInvalidHello)434 return fmt.Errorf("%w: geometry", ErrInvalidHello)
416 }435 }
417- if !validEnvironmentPolicy(h.EnvironmentPolicy) {436+ if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() {
418 return ErrInvalidHello437 return ErrInvalidHello
419 }438 }
420 if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil {439 if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil {
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(62), Version)
13+ require.Equal(t, uint16(63), Version)
1414 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
1515 }
1616
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 func TestVersionRemainsWireCompatible(t *testing.T) {12 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(62), Version)13+ require.Equal(t, uint16(63), Version)
14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
15 }15 }
16 16
M internal/protocol/wire/envelope.pb.go +84 -64 generated file: collapsed
M internal/protocol/wire/envelope_inventory_test.go +3 -3
@@ -15,8 +15,8 @@ import (
1515 // shares a payload type except route navigation failure, and the preamble
1616 // constants agree with the schema authority.
1717 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 62 {
19- t.Fatalf("protocol.Version = %d, want 62", protocol.Version)
18+ if protocol.Version != 63 {
19+ t.Fatalf("protocol.Version = %d, want 63", protocol.Version)
2020 }
2121 if ProtocolEpoch != 1 {
2222 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
@@ -40,7 +40,7 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
4040 "ui_fence": true, "navigation_inventory_request": true,
4141 "navigation_inventory_publication": true, "navigation_inventory_failure": true,
4242 "picker_close": true, "picker_selection": true,
43- "select_tab": true,
43+ "select_tab": true, "terminal_focus": true,
4444 }
4545 serverSemantic := map[string]bool{
4646 "attachment_suspended": true, "attachment_activated": true,
@@ -15,8 +15,8 @@ import (
15 // shares a payload type except route navigation failure, and the preamble15 // shares a payload type except route navigation failure, and the preamble
16 // constants agree with the schema authority.16 // constants agree with the schema authority.
17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 62 {18+ if protocol.Version != 63 {
19- t.Fatalf("protocol.Version = %d, want 62", protocol.Version)19+ t.Fatalf("protocol.Version = %d, want 63", protocol.Version)
20 }20 }
21 if ProtocolEpoch != 1 {21 if ProtocolEpoch != 1 {
22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
@@ -40,7 +40,7 @@ func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
40 "ui_fence": true, "navigation_inventory_request": true,40 "ui_fence": true, "navigation_inventory_request": true,
41 "navigation_inventory_publication": true, "navigation_inventory_failure": true,41 "navigation_inventory_publication": true, "navigation_inventory_failure": true,
42 "picker_close": true, "picker_selection": true,42 "picker_close": true, "picker_selection": true,
43- "select_tab": true,43+ "select_tab": true, "terminal_focus": true,
44 }44 }
45 serverSemantic := map[string]bool{45 serverSemantic := map[string]bool{
46 "attachment_suspended": true, "attachment_activated": true,46 "attachment_suspended": true, "attachment_activated": true,
M internal/protocol/wire/schema/envelope.proto +1 -0
@@ -99,6 +99,7 @@ message ClientEnvelope {
9999 ActivateAttachment activate_attachment = 30;
100100 SelectTab select_tab = 31;
101101 RemotePreviewWatch remote_preview_watch = 32;
102+ TerminalFocus terminal_focus = 33;
102103 }
103104 }
104105
@@ -99,6 +99,7 @@ message ClientEnvelope {
99 ActivateAttachment activate_attachment = 30;99 ActivateAttachment activate_attachment = 30;
100 SelectTab select_tab = 31;100 SelectTab select_tab = 31;
101 RemotePreviewWatch remote_preview_watch = 32;101 RemotePreviewWatch remote_preview_watch = 32;
102+ TerminalFocus terminal_focus = 33;
102 }103 }
103 }104 }
104 105
M internal/protocol/wire/schema/session.proto +3 -0
@@ -34,6 +34,9 @@ message Hello {
3434 bool kitty_direct_graphics = 21;
3535 SessionAttachTarget session_target = 22; // optional pointer
3636 bool kitty_keyboard = 23;
37+ // Terminal window focus when the attachment opens: 0 = unknown,
38+ // 1 = focused, 2 = unfocused. Later changes travel as TerminalFocus.
39+ uint32 terminal_focus = 24;
3740 }
3841
3942 // Welcome answers an accepted attach.
@@ -34,6 +34,9 @@ message Hello {
34 bool kitty_direct_graphics = 21;34 bool kitty_direct_graphics = 21;
35 SessionAttachTarget session_target = 22; // optional pointer35 SessionAttachTarget session_target = 22; // optional pointer
36 bool kitty_keyboard = 23;36 bool kitty_keyboard = 23;
37+ // Terminal window focus when the attachment opens: 0 = unknown,
38+ // 1 = focused, 2 = unfocused. Later changes travel as TerminalFocus.
39+ uint32 terminal_focus = 24;
37 }40 }
38 41
39 // Welcome answers an accepted attach.42 // Welcome answers an accepted attach.
M internal/protocol/wire/schema/terminal.proto +6 -0
@@ -86,6 +86,12 @@ message ClientNotice {
8686 // OutputResetRequest restarts the output chain. Empty marker.
8787 message OutputResetRequest {}
8888
89+// TerminalFocus reports a focus change of the client's terminal window.
90+message TerminalFocus {
91+ // Closed focus taxonomy: 1 = focused, 2 = unfocused.
92+ uint32 focus = 1;
93+}
94+
8995 // SelectTab shows one exact tab of the attached session on this attachment.
9096 message SelectTab {
9197 string tab_id = 1;
@@ -86,6 +86,12 @@ message ClientNotice {
86 // OutputResetRequest restarts the output chain. Empty marker.86 // OutputResetRequest restarts the output chain. Empty marker.
87 message OutputResetRequest {}87 message OutputResetRequest {}
88 88
89+// TerminalFocus reports a focus change of the client's terminal window.
90+message TerminalFocus {
91+ // Closed focus taxonomy: 1 = focused, 2 = unfocused.
92+ uint32 focus = 1;
93+}
94+
89 // SelectTab shows one exact tab of the attached session on this attachment.95 // SelectTab shows one exact tab of the attached session on this attachment.
90 message SelectTab {96 message SelectTab {
91 string tab_id = 1;97 string tab_id = 1;
M internal/protocol/wire/session.pb.go +15 -4 generated file: collapsed
M internal/protocol/wire/terminal.pb.go +93 -44 generated file: collapsed
M internal/usecase/client/attachment_session.go +28 -1
@@ -324,8 +324,27 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
324324 if err := input.start(ctx); err != nil {
325325 return w.settle(ctx, fg, stream, token, err)
326326 }
327+ // Hello declared the focus known at attach, for the first paint. The
328+ // reporter re-sends the current focus, which the daemon ignores when
329+ // unchanged, then every change. The pump records a focus report before it
330+ // delivers the input read with it, so flushing focus first keeps the
331+ // daemon's view in input order.
332+ focus := &focusReporter{state: attachmentTerminalFocus(fg)}
333+ sendFocus := func() error {
334+ if message, ok := focus.next(); ok {
335+ return w.send(ctx, fg, stream, message)
336+ }
337+ return nil
338+ }
339+ if err := sendFocus(); err != nil {
340+ return w.settle(ctx, fg, stream, token, err)
341+ }
327342 for {
328343 select {
344+ case <-focus.changed:
345+ if err := sendFocus(); err != nil {
346+ return w.settle(ctx, fg, stream, token, err)
347+ }
329348 case <-input.wake:
330349 if err := input.flush(ctx); err != nil {
331350 return w.settle(ctx, fg, stream, token, err)
@@ -480,6 +499,12 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
480499 fg.PreserveInput(event.input.Data)
481500 return w.settle(ctx, fg, stream, token, event.input.Err)
482501 }
502+ if event.input != nil {
503+ if err := sendFocus(); err != nil {
504+ fg.PreserveInput(event.input.Data)
505+ return w.settle(ctx, fg, stream, token, err)
506+ }
507+ }
483508 if event.input != nil && event.input.actionID == 0 {
484509 // A physical read: replies are stripped and ordinary bytes
485510 // reach the overlay or the session inside read.
@@ -685,7 +710,9 @@ func (w *sessionAttachmentWorker) validateIdentity(stage string, got protocol.Ex
685710
686711 // sendHello sends the typed Hello derived from the exact opened request.
687712 func (w *sessionAttachmentWorker) sendHello(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection) error {
688- return w.send(ctx, fg, stream, w.hello(stream))
713+ hello := w.hello(stream)
714+ hello.TerminalFocus = attachmentTerminalFocus(fg).Focus()
715+ return w.send(ctx, fg, stream, hello)
689716 }
690717
691718 // hello builds the Hello the daemon expects for exactly one opened request. No
@@ -324,8 +324,27 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
324 if err := input.start(ctx); err != nil {324 if err := input.start(ctx); err != nil {
325 return w.settle(ctx, fg, stream, token, err)325 return w.settle(ctx, fg, stream, token, err)
326 }326 }
327+ // Hello declared the focus known at attach, for the first paint. The
328+ // reporter re-sends the current focus, which the daemon ignores when
329+ // unchanged, then every change. The pump records a focus report before it
330+ // delivers the input read with it, so flushing focus first keeps the
331+ // daemon's view in input order.
332+ focus := &focusReporter{state: attachmentTerminalFocus(fg)}
333+ sendFocus := func() error {
334+ if message, ok := focus.next(); ok {
335+ return w.send(ctx, fg, stream, message)
336+ }
337+ return nil
338+ }
339+ if err := sendFocus(); err != nil {
340+ return w.settle(ctx, fg, stream, token, err)
341+ }
327 for {342 for {
328 select {343 select {
344+ case <-focus.changed:
345+ if err := sendFocus(); err != nil {
346+ return w.settle(ctx, fg, stream, token, err)
347+ }
329 case <-input.wake:348 case <-input.wake:
330 if err := input.flush(ctx); err != nil {349 if err := input.flush(ctx); err != nil {
331 return w.settle(ctx, fg, stream, token, err)350 return w.settle(ctx, fg, stream, token, err)
@@ -480,6 +499,12 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
480 fg.PreserveInput(event.input.Data)499 fg.PreserveInput(event.input.Data)
481 return w.settle(ctx, fg, stream, token, event.input.Err)500 return w.settle(ctx, fg, stream, token, event.input.Err)
482 }501 }
502+ if event.input != nil {
503+ if err := sendFocus(); err != nil {
504+ fg.PreserveInput(event.input.Data)
505+ return w.settle(ctx, fg, stream, token, err)
506+ }
507+ }
483 if event.input != nil && event.input.actionID == 0 {508 if event.input != nil && event.input.actionID == 0 {
484 // A physical read: replies are stripped and ordinary bytes509 // A physical read: replies are stripped and ordinary bytes
485 // reach the overlay or the session inside read.510 // reach the overlay or the session inside read.
@@ -685,7 +710,9 @@ func (w *sessionAttachmentWorker) validateIdentity(stage string, got protocol.Ex
685 710
686 // sendHello sends the typed Hello derived from the exact opened request.711 // sendHello sends the typed Hello derived from the exact opened request.
687 func (w *sessionAttachmentWorker) sendHello(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection) error {712 func (w *sessionAttachmentWorker) sendHello(ctx context.Context, fg AttachmentForeground, stream ports.BrokerLogicalConnection) error {
688- return w.send(ctx, fg, stream, w.hello(stream))713+ hello := w.hello(stream)
714+ hello.TerminalFocus = attachmentTerminalFocus(fg).Focus()
715+ return w.send(ctx, fg, stream, hello)
689 }716 }
690 717
691 // hello builds the Hello the daemon expects for exactly one opened request. No718 // hello builds the Hello the daemon expects for exactly one opened request. No
A internal/usecase/client/terminal_focus.go +145 -0
@@ -0,0 +1,145 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "sync"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/protocol"
9+)
10+
11+// Terminal focus.
12+//
13+// The client turns on focus reporting (DEC mode 1004) with its other terminal
14+// modes. The terminal then sends ESC [ I when its window gains focus and
15+// ESC [ O when it loses it. terminalFocusScanner strips both reports from the
16+// input stream and keeps the latest state, so any client feature can read
17+// Focus() and the attached loop tells the daemon about each change.
18+//
19+// A terminal without focus reporting sends nothing: focus stays unknown and
20+// every feature keeps its pre-focus behavior.
21+
22+var (
23+ terminalFocusInReport = []byte("\x1b[I")
24+ terminalFocusOutReport = []byte("\x1b[O")
25+)
26+
27+// TerminalFocusState is the client's one terminal focus. The terminal input
28+// pump is its only writer; any client feature may read it or wait for a
29+// change. It outlives attachments, so a new attachment starts from the focus
30+// the terminal last reported.
31+type TerminalFocusState struct {
32+ mu sync.Mutex
33+ focus domain.TerminalFocus
34+ changed chan struct{}
35+}
36+
37+func newTerminalFocusState() *TerminalFocusState {
38+ return &TerminalFocusState{changed: make(chan struct{})}
39+}
40+
41+// Focus returns the latest reported focus. A nil state is unknown.
42+func (s *TerminalFocusState) Focus() domain.TerminalFocus {
43+ if s == nil {
44+ return domain.TerminalFocusUnknown
45+ }
46+ s.mu.Lock()
47+ defer s.mu.Unlock()
48+ return s.focus
49+}
50+
51+// Watch returns the current focus and a channel closed on the next change.
52+func (s *TerminalFocusState) Watch() (domain.TerminalFocus, <-chan struct{}) {
53+ if s == nil {
54+ return domain.TerminalFocusUnknown, nil
55+ }
56+ s.mu.Lock()
57+ defer s.mu.Unlock()
58+ return s.focus, s.changed
59+}
60+
61+func (s *TerminalFocusState) set(focus domain.TerminalFocus) {
62+ s.mu.Lock()
63+ defer s.mu.Unlock()
64+ if focus == s.focus {
65+ return
66+ }
67+ s.focus = focus
68+ close(s.changed)
69+ s.changed = make(chan struct{})
70+}
71+
72+// attachmentFocusForeground is the optional seam through which an attached
73+// loop reads the terminal focus.
74+type attachmentFocusForeground interface {
75+ terminalFocus() *TerminalFocusState
76+}
77+
78+func (f *attachmentForeground) terminalFocus() *TerminalFocusState {
79+ if f == nil || f.input == nil {
80+ return nil
81+ }
82+ return f.input.focus
83+}
84+
85+// attachmentTerminalFocus returns the focus state of fg, or nil.
86+func attachmentTerminalFocus(fg AttachmentForeground) *TerminalFocusState {
87+ if focus, ok := fg.(attachmentFocusForeground); ok {
88+ return focus.terminalFocus()
89+ }
90+ return nil
91+}
92+
93+// focusReporter sends the terminal focus to the daemon: the current state once
94+// the attachment starts, then every change. Unknown focus is never sent.
95+type focusReporter struct {
96+ state *TerminalFocusState
97+ sent domain.TerminalFocus
98+ changed <-chan struct{}
99+}
100+
101+// next returns the message to send now, if any, and re-arms the change wake.
102+func (r *focusReporter) next() (protocol.TerminalFocus, bool) {
103+ focus, changed := r.state.Watch()
104+ r.changed = changed
105+ if focus == domain.TerminalFocusUnknown || focus == r.sent {
106+ return protocol.TerminalFocus{}, false
107+ }
108+ r.sent = focus
109+ return protocol.TerminalFocus{Focus: focus}, true
110+}
111+
112+// stripTerminalFocusReports removes complete focus reports from data and
113+// records the last one in state. It never withholds a prefix: a terminal
114+// writes each report whole, while a lone Escape or Alt+[ typed by the user
115+// must reach the session without delay. Other bytes keep their order. A
116+// pasted literal report, or one split across reads, is accepted as rare.
117+func stripTerminalFocusReports(state *TerminalFocusState, data []byte) []byte {
118+ at, focus := nextTerminalFocusReport(data)
119+ if at < 0 {
120+ return data
121+ }
122+ out := make([]byte, 0, len(data))
123+ for at >= 0 {
124+ out = append(out, data[:at]...)
125+ state.set(focus)
126+ data = data[at+len(terminalFocusInReport):]
127+ at, focus = nextTerminalFocusReport(data)
128+ }
129+ return append(out, data...)
130+}
131+
132+// nextTerminalFocusReport returns the offset of the first complete report in
133+// data, or -1.
134+func nextTerminalFocusReport(data []byte) (int, domain.TerminalFocus) {
135+ in := bytes.Index(data, terminalFocusInReport)
136+ out := bytes.Index(data, terminalFocusOutReport)
137+ switch {
138+ case in < 0 && out < 0:
139+ return -1, domain.TerminalFocusUnknown
140+ case out < 0 || (in >= 0 && in < out):
141+ return in, domain.TerminalFocusFocused
142+ default:
143+ return out, domain.TerminalFocusUnfocused
144+ }
145+}
@@ -0,0 +1,145 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "sync"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/protocol"
9+)
10+
11+// Terminal focus.
12+//
13+// The client turns on focus reporting (DEC mode 1004) with its other terminal
14+// modes. The terminal then sends ESC [ I when its window gains focus and
15+// ESC [ O when it loses it. terminalFocusScanner strips both reports from the
16+// input stream and keeps the latest state, so any client feature can read
17+// Focus() and the attached loop tells the daemon about each change.
18+//
19+// A terminal without focus reporting sends nothing: focus stays unknown and
20+// every feature keeps its pre-focus behavior.
21+
22+var (
23+ terminalFocusInReport = []byte("\x1b[I")
24+ terminalFocusOutReport = []byte("\x1b[O")
25+)
26+
27+// TerminalFocusState is the client's one terminal focus. The terminal input
28+// pump is its only writer; any client feature may read it or wait for a
29+// change. It outlives attachments, so a new attachment starts from the focus
30+// the terminal last reported.
31+type TerminalFocusState struct {
32+ mu sync.Mutex
33+ focus domain.TerminalFocus
34+ changed chan struct{}
35+}
36+
37+func newTerminalFocusState() *TerminalFocusState {
38+ return &TerminalFocusState{changed: make(chan struct{})}
39+}
40+
41+// Focus returns the latest reported focus. A nil state is unknown.
42+func (s *TerminalFocusState) Focus() domain.TerminalFocus {
43+ if s == nil {
44+ return domain.TerminalFocusUnknown
45+ }
46+ s.mu.Lock()
47+ defer s.mu.Unlock()
48+ return s.focus
49+}
50+
51+// Watch returns the current focus and a channel closed on the next change.
52+func (s *TerminalFocusState) Watch() (domain.TerminalFocus, <-chan struct{}) {
53+ if s == nil {
54+ return domain.TerminalFocusUnknown, nil
55+ }
56+ s.mu.Lock()
57+ defer s.mu.Unlock()
58+ return s.focus, s.changed
59+}
60+
61+func (s *TerminalFocusState) set(focus domain.TerminalFocus) {
62+ s.mu.Lock()
63+ defer s.mu.Unlock()
64+ if focus == s.focus {
65+ return
66+ }
67+ s.focus = focus
68+ close(s.changed)
69+ s.changed = make(chan struct{})
70+}
71+
72+// attachmentFocusForeground is the optional seam through which an attached
73+// loop reads the terminal focus.
74+type attachmentFocusForeground interface {
75+ terminalFocus() *TerminalFocusState
76+}
77+
78+func (f *attachmentForeground) terminalFocus() *TerminalFocusState {
79+ if f == nil || f.input == nil {
80+ return nil
81+ }
82+ return f.input.focus
83+}
84+
85+// attachmentTerminalFocus returns the focus state of fg, or nil.
86+func attachmentTerminalFocus(fg AttachmentForeground) *TerminalFocusState {
87+ if focus, ok := fg.(attachmentFocusForeground); ok {
88+ return focus.terminalFocus()
89+ }
90+ return nil
91+}
92+
93+// focusReporter sends the terminal focus to the daemon: the current state once
94+// the attachment starts, then every change. Unknown focus is never sent.
95+type focusReporter struct {
96+ state *TerminalFocusState
97+ sent domain.TerminalFocus
98+ changed <-chan struct{}
99+}
100+
101+// next returns the message to send now, if any, and re-arms the change wake.
102+func (r *focusReporter) next() (protocol.TerminalFocus, bool) {
103+ focus, changed := r.state.Watch()
104+ r.changed = changed
105+ if focus == domain.TerminalFocusUnknown || focus == r.sent {
106+ return protocol.TerminalFocus{}, false
107+ }
108+ r.sent = focus
109+ return protocol.TerminalFocus{Focus: focus}, true
110+}
111+
112+// stripTerminalFocusReports removes complete focus reports from data and
113+// records the last one in state. It never withholds a prefix: a terminal
114+// writes each report whole, while a lone Escape or Alt+[ typed by the user
115+// must reach the session without delay. Other bytes keep their order. A
116+// pasted literal report, or one split across reads, is accepted as rare.
117+func stripTerminalFocusReports(state *TerminalFocusState, data []byte) []byte {
118+ at, focus := nextTerminalFocusReport(data)
119+ if at < 0 {
120+ return data
121+ }
122+ out := make([]byte, 0, len(data))
123+ for at >= 0 {
124+ out = append(out, data[:at]...)
125+ state.set(focus)
126+ data = data[at+len(terminalFocusInReport):]
127+ at, focus = nextTerminalFocusReport(data)
128+ }
129+ return append(out, data...)
130+}
131+
132+// nextTerminalFocusReport returns the offset of the first complete report in
133+// data, or -1.
134+func nextTerminalFocusReport(data []byte) (int, domain.TerminalFocus) {
135+ in := bytes.Index(data, terminalFocusInReport)
136+ out := bytes.Index(data, terminalFocusOutReport)
137+ switch {
138+ case in < 0 && out < 0:
139+ return -1, domain.TerminalFocusUnknown
140+ case out < 0 || (in >= 0 && in < out):
141+ return in, domain.TerminalFocusFocused
142+ default:
143+ return out, domain.TerminalFocusUnfocused
144+ }
145+}
A internal/usecase/client/terminal_focus_test.go +171 -0
@@ -0,0 +1,171 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/protocol"
14+)
15+
16+func TestStripTerminalFocusReports(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ start domain.TerminalFocus
20+ input string
21+ want string
22+ wantFocus domain.TerminalFocus
23+ changed bool
24+ }{
25+ {name: "plain input passes through", input: "ls\r", want: "ls\r"},
26+ {name: "focus in only", input: "\x1b[I", want: "", wantFocus: domain.TerminalFocusFocused, changed: true},
27+ {name: "focus out only", start: domain.TerminalFocusFocused, input: "\x1b[O", want: "", wantFocus: domain.TerminalFocusUnfocused, changed: true},
28+ {name: "last report wins", input: "\x1b[I\x1b[O", want: "", wantFocus: domain.TerminalFocusUnfocused, changed: true},
29+ {name: "keys around a report keep their order", input: "a\x1b[Ib", want: "ab", wantFocus: domain.TerminalFocusFocused, changed: true},
30+ {name: "arrow keys and csi are untouched", input: "\x1b[A\x1b[1;5C\x1b[<0;1;1M", want: "\x1b[A\x1b[1;5C\x1b[<0;1;1M"},
31+ {name: "a lone escape is never withheld", input: "\x1b", want: "\x1b"},
32+ {name: "a split report is forwarded as typed", input: "\x1b[", want: "\x1b["},
33+ {name: "a repeated state changes nothing", start: domain.TerminalFocusFocused, input: "\x1b[I", want: "", wantFocus: domain.TerminalFocusFocused},
34+ }
35+ for _, tt := range tests {
36+ t.Run(tt.name, func(t *testing.T) {
37+ state := newTerminalFocusState()
38+ state.set(tt.start)
39+ _, changed := state.Watch()
40+
41+ got := stripTerminalFocusReports(state, []byte(tt.input))
42+
43+ require.Equal(t, tt.want, string(got))
44+ want := tt.wantFocus
45+ if want == domain.TerminalFocusUnknown {
46+ want = tt.start
47+ }
48+ require.Equal(t, want, state.Focus())
49+ select {
50+ case <-changed:
51+ require.True(t, tt.changed, "no change was expected")
52+ default:
53+ require.False(t, tt.changed, "a change was expected")
54+ }
55+ })
56+ }
57+}
58+
59+func TestFocusReporterSendsKnownChangesOnce(t *testing.T) {
60+ state := newTerminalFocusState()
61+ reporter := &focusReporter{state: state}
62+
63+ _, ok := reporter.next()
64+ require.False(t, ok, "unknown focus is never sent")
65+
66+ steps := []struct {
67+ focus domain.TerminalFocus
68+ want protocol.TerminalFocus
69+ wantOK bool
70+ }{
71+ {focus: domain.TerminalFocusFocused, want: protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}, wantOK: true},
72+ {focus: domain.TerminalFocusUnfocused, want: protocol.TerminalFocus{Focus: domain.TerminalFocusUnfocused}, wantOK: true},
73+ {focus: domain.TerminalFocusUnfocused},
74+ }
75+ for _, step := range steps {
76+ wake := reporter.changed
77+ state.set(step.focus)
78+ if step.wantOK {
79+ select {
80+ case <-wake:
81+ default:
82+ t.Fatalf("a change to %s must wake the reporter", step.focus)
83+ }
84+ }
85+ got, ok := reporter.next()
86+ require.Equal(t, step.wantOK, ok)
87+ require.Equal(t, step.want, got)
88+ }
89+}
90+
91+// TestTerminalInputPumpStripsFocusReports proves focus reports never reach an
92+// attachment: a read that only carried a report delivers nothing, the focus
93+// the terminal reported is readable on the foreground, and later keys arrive
94+// intact.
95+func TestTerminalInputPumpStripsFocusReports(t *testing.T) {
96+ pr, pw := io.Pipe()
97+ pump := newTerminalInputPump(pr)
98+ pump.start()
99+ defer pump.stop()
100+ defer pw.Close()
101+ host := newWorkerTestHost(newWorkerTestTerminal(), pump, nil)
102+
103+ observed := make(chan []byte, 1)
104+ focus := make(chan domain.TerminalFocus, 1)
105+ worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent {
106+ event, ok := fg.Input(ctx)
107+ if !ok {
108+ return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("no input")}
109+ }
110+ fg.AckInput()
111+ observed <- append([]byte(nil), event.Data...)
112+ focus <- attachmentTerminalFocus(fg).Focus()
113+ return AttachmentEvent{Kind: AttachmentEventEnded}
114+ }}
115+
116+ go func() {
117+ _, _ = pw.Write([]byte("\x1b[O"))
118+ _, _ = pw.Write([]byte("\x1b[Iab"))
119+ }()
120+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
121+ defer cancel()
122+ _, adopted := host.Run(ctx, AttachmentToken{Generation: 1, Attempt: 1}, worker, newWorkerTestStream())
123+ require.True(t, adopted)
124+
125+ require.Equal(t, []byte("ab"), <-observed, "the focus-only read is never delivered")
126+ require.Equal(t, domain.TerminalFocusFocused, <-focus)
127+}
128+
129+func TestNilTerminalFocusStateIsUnknown(t *testing.T) {
130+ var state *TerminalFocusState
131+ focus, changed := state.Watch()
132+ require.Equal(t, domain.TerminalFocusUnknown, focus)
133+ require.Nil(t, changed)
134+ require.Equal(t, domain.TerminalFocusUnknown, state.Focus())
135+}
136+
137+// TestAttachmentDeclaresFocusInHelloThenReportsChanges proves the daemon
138+// learns a terminal's focus before its first paint: Hello carries the focus
139+// already known, and each later change follows as TerminalFocus.
140+func TestAttachmentDeclaresFocusInHelloThenReportsChanges(t *testing.T) {
141+ pump := newTerminalInputPump(blockingReader{})
142+ pump.focus.set(domain.TerminalFocusUnfocused)
143+ stream := newSessionTestStream()
144+ host := newWorkerTestHost(newWorkerTestTerminal(), pump, nil)
145+ worker, err := newSessionAttachmentWorker(sessionTestWorkerConfig(sessionTestRequest(true)))
146+ require.NoError(t, err)
147+ ctx, cancel := context.WithCancel(context.Background())
148+ defer cancel()
149+ go func() { _, _ = host.Run(ctx, AttachmentToken{Generation: 4, Attempt: 1}, worker, stream) }()
150+
151+ hello := awaitHello(t, stream)
152+ require.Equal(t, domain.TerminalFocusUnfocused, hello.TerminalFocus)
153+ require.NoError(t, protocol.ValidateHello(hello))
154+
155+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
156+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
157+ pump.focus.set(domain.TerminalFocusFocused)
158+ require.Eventually(t, func() bool {
159+ for _, message := range stream.messages() {
160+ if message == (protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}) {
161+ return true
162+ }
163+ }
164+ return false
165+ }, 5*time.Second, time.Millisecond, "a focus change after attach is reported")
166+}
167+
168+// blockingReader is a terminal that never sends input.
169+type blockingReader struct{}
170+
171+func (blockingReader) Read([]byte) (int, error) { select {} }
@@ -0,0 +1,171 @@
1+package client
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/protocol"
14+)
15+
16+func TestStripTerminalFocusReports(t *testing.T) {
17+ tests := []struct {
18+ name string
19+ start domain.TerminalFocus
20+ input string
21+ want string
22+ wantFocus domain.TerminalFocus
23+ changed bool
24+ }{
25+ {name: "plain input passes through", input: "ls\r", want: "ls\r"},
26+ {name: "focus in only", input: "\x1b[I", want: "", wantFocus: domain.TerminalFocusFocused, changed: true},
27+ {name: "focus out only", start: domain.TerminalFocusFocused, input: "\x1b[O", want: "", wantFocus: domain.TerminalFocusUnfocused, changed: true},
28+ {name: "last report wins", input: "\x1b[I\x1b[O", want: "", wantFocus: domain.TerminalFocusUnfocused, changed: true},
29+ {name: "keys around a report keep their order", input: "a\x1b[Ib", want: "ab", wantFocus: domain.TerminalFocusFocused, changed: true},
30+ {name: "arrow keys and csi are untouched", input: "\x1b[A\x1b[1;5C\x1b[<0;1;1M", want: "\x1b[A\x1b[1;5C\x1b[<0;1;1M"},
31+ {name: "a lone escape is never withheld", input: "\x1b", want: "\x1b"},
32+ {name: "a split report is forwarded as typed", input: "\x1b[", want: "\x1b["},
33+ {name: "a repeated state changes nothing", start: domain.TerminalFocusFocused, input: "\x1b[I", want: "", wantFocus: domain.TerminalFocusFocused},
34+ }
35+ for _, tt := range tests {
36+ t.Run(tt.name, func(t *testing.T) {
37+ state := newTerminalFocusState()
38+ state.set(tt.start)
39+ _, changed := state.Watch()
40+
41+ got := stripTerminalFocusReports(state, []byte(tt.input))
42+
43+ require.Equal(t, tt.want, string(got))
44+ want := tt.wantFocus
45+ if want == domain.TerminalFocusUnknown {
46+ want = tt.start
47+ }
48+ require.Equal(t, want, state.Focus())
49+ select {
50+ case <-changed:
51+ require.True(t, tt.changed, "no change was expected")
52+ default:
53+ require.False(t, tt.changed, "a change was expected")
54+ }
55+ })
56+ }
57+}
58+
59+func TestFocusReporterSendsKnownChangesOnce(t *testing.T) {
60+ state := newTerminalFocusState()
61+ reporter := &focusReporter{state: state}
62+
63+ _, ok := reporter.next()
64+ require.False(t, ok, "unknown focus is never sent")
65+
66+ steps := []struct {
67+ focus domain.TerminalFocus
68+ want protocol.TerminalFocus
69+ wantOK bool
70+ }{
71+ {focus: domain.TerminalFocusFocused, want: protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}, wantOK: true},
72+ {focus: domain.TerminalFocusUnfocused, want: protocol.TerminalFocus{Focus: domain.TerminalFocusUnfocused}, wantOK: true},
73+ {focus: domain.TerminalFocusUnfocused},
74+ }
75+ for _, step := range steps {
76+ wake := reporter.changed
77+ state.set(step.focus)
78+ if step.wantOK {
79+ select {
80+ case <-wake:
81+ default:
82+ t.Fatalf("a change to %s must wake the reporter", step.focus)
83+ }
84+ }
85+ got, ok := reporter.next()
86+ require.Equal(t, step.wantOK, ok)
87+ require.Equal(t, step.want, got)
88+ }
89+}
90+
91+// TestTerminalInputPumpStripsFocusReports proves focus reports never reach an
92+// attachment: a read that only carried a report delivers nothing, the focus
93+// the terminal reported is readable on the foreground, and later keys arrive
94+// intact.
95+func TestTerminalInputPumpStripsFocusReports(t *testing.T) {
96+ pr, pw := io.Pipe()
97+ pump := newTerminalInputPump(pr)
98+ pump.start()
99+ defer pump.stop()
100+ defer pw.Close()
101+ host := newWorkerTestHost(newWorkerTestTerminal(), pump, nil)
102+
103+ observed := make(chan []byte, 1)
104+ focus := make(chan domain.TerminalFocus, 1)
105+ worker := &fakeWorker{run: func(ctx context.Context, fg AttachmentForeground) AttachmentEvent {
106+ event, ok := fg.Input(ctx)
107+ if !ok {
108+ return AttachmentEvent{Kind: AttachmentEventFailed, Err: errors.New("no input")}
109+ }
110+ fg.AckInput()
111+ observed <- append([]byte(nil), event.Data...)
112+ focus <- attachmentTerminalFocus(fg).Focus()
113+ return AttachmentEvent{Kind: AttachmentEventEnded}
114+ }}
115+
116+ go func() {
117+ _, _ = pw.Write([]byte("\x1b[O"))
118+ _, _ = pw.Write([]byte("\x1b[Iab"))
119+ }()
120+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
121+ defer cancel()
122+ _, adopted := host.Run(ctx, AttachmentToken{Generation: 1, Attempt: 1}, worker, newWorkerTestStream())
123+ require.True(t, adopted)
124+
125+ require.Equal(t, []byte("ab"), <-observed, "the focus-only read is never delivered")
126+ require.Equal(t, domain.TerminalFocusFocused, <-focus)
127+}
128+
129+func TestNilTerminalFocusStateIsUnknown(t *testing.T) {
130+ var state *TerminalFocusState
131+ focus, changed := state.Watch()
132+ require.Equal(t, domain.TerminalFocusUnknown, focus)
133+ require.Nil(t, changed)
134+ require.Equal(t, domain.TerminalFocusUnknown, state.Focus())
135+}
136+
137+// TestAttachmentDeclaresFocusInHelloThenReportsChanges proves the daemon
138+// learns a terminal's focus before its first paint: Hello carries the focus
139+// already known, and each later change follows as TerminalFocus.
140+func TestAttachmentDeclaresFocusInHelloThenReportsChanges(t *testing.T) {
141+ pump := newTerminalInputPump(blockingReader{})
142+ pump.focus.set(domain.TerminalFocusUnfocused)
143+ stream := newSessionTestStream()
144+ host := newWorkerTestHost(newWorkerTestTerminal(), pump, nil)
145+ worker, err := newSessionAttachmentWorker(sessionTestWorkerConfig(sessionTestRequest(true)))
146+ require.NoError(t, err)
147+ ctx, cancel := context.WithCancel(context.Background())
148+ defer cancel()
149+ go func() { _, _ = host.Run(ctx, AttachmentToken{Generation: 4, Attempt: 1}, worker, stream) }()
150+
151+ hello := awaitHello(t, stream)
152+ require.Equal(t, domain.TerminalFocusUnfocused, hello.TerminalFocus)
153+ require.NoError(t, protocol.ValidateHello(hello))
154+
155+ stream.deliver(protocol.Welcome{SessionName: "alpha"})
156+ stream.deliver(sessionTestOutput(1, "\x1b[Hready"))
157+ pump.focus.set(domain.TerminalFocusFocused)
158+ require.Eventually(t, func() bool {
159+ for _, message := range stream.messages() {
160+ if message == (protocol.TerminalFocus{Focus: domain.TerminalFocusFocused}) {
161+ return true
162+ }
163+ }
164+ return false
165+ }, 5*time.Second, time.Millisecond, "a focus change after attach is reported")
166+}
167+
168+// blockingReader is a terminal that never sends input.
169+type blockingReader struct{}
170+
171+func (blockingReader) Read([]byte) (int, error) { select {} }
M internal/usecase/client/terminal_input_pump.go +11 -2
@@ -48,7 +48,10 @@ type terminalAutomationRequest struct {
4848 // after Run exits; suspend drops any result received while inactive and never
4949 // closes caller-owned stdin. Reconnects and later runs never add another reader.
5050 type terminalInputPump struct {
51- in io.Reader
51+ in io.Reader
52+ // focus is the terminal focus decoded from this reader. The pump strips
53+ // focus reports before any consumer sees the bytes.
54+ focus *TerminalFocusState
5255 done chan struct{}
5356 automation chan terminalAutomationRequest
5457
@@ -87,6 +90,7 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump {
8790 space <- struct{}{}
8891 return &terminalInputPump{
8992 in: in,
93+ focus: newTerminalFocusState(),
9094 done: make(chan struct{}),
9195 automation: make(chan terminalAutomationRequest),
9296 activation: 1,
@@ -339,7 +343,12 @@ func (p *terminalInputPump) start() {
339343 n, err := p.in.Read(buf)
340344 result := terminalReadResult{err: err}
341345 if n > 0 {
342- result.data = append([]byte(nil), buf[:n]...)
346+ if data := stripTerminalFocusReports(p.focus, buf[:n]); len(data) != 0 {
347+ result.data = append([]byte(nil), data...)
348+ } else if err == nil {
349+ // A read that only carried focus reports delivers nothing.
350+ continue
351+ }
343352 }
344353 if !p.enqueue(result, activation) {
345354 if p.isClosed() {
@@ -48,7 +48,10 @@ type terminalAutomationRequest struct {
48 // after Run exits; suspend drops any result received while inactive and never48 // after Run exits; suspend drops any result received while inactive and never
49 // closes caller-owned stdin. Reconnects and later runs never add another reader.49 // closes caller-owned stdin. Reconnects and later runs never add another reader.
50 type terminalInputPump struct {50 type terminalInputPump struct {
51- in io.Reader51+ in io.Reader
52+ // focus is the terminal focus decoded from this reader. The pump strips
53+ // focus reports before any consumer sees the bytes.
54+ focus *TerminalFocusState
52 done chan struct{}55 done chan struct{}
53 automation chan terminalAutomationRequest56 automation chan terminalAutomationRequest
54 57
@@ -87,6 +90,7 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump {
87 space <- struct{}{}90 space <- struct{}{}
88 return &terminalInputPump{91 return &terminalInputPump{
89 in: in,92 in: in,
93+ focus: newTerminalFocusState(),
90 done: make(chan struct{}),94 done: make(chan struct{}),
91 automation: make(chan terminalAutomationRequest),95 automation: make(chan terminalAutomationRequest),
92 activation: 1,96 activation: 1,
@@ -339,7 +343,12 @@ func (p *terminalInputPump) start() {
339 n, err := p.in.Read(buf)343 n, err := p.in.Read(buf)
340 result := terminalReadResult{err: err}344 result := terminalReadResult{err: err}
341 if n > 0 {345 if n > 0 {
342- result.data = append([]byte(nil), buf[:n]...)346+ if data := stripTerminalFocusReports(p.focus, buf[:n]); len(data) != 0 {
347+ result.data = append([]byte(nil), data...)
348+ } else if err == nil {
349+ // A read that only carried focus reports delivers nothing.
350+ continue
351+ }
343 }352 }
344 if !p.enqueue(result, activation) {353 if !p.enqueue(result, activation) {
345 if p.isClosed() {354 if p.isClosed() {
M internal/usecase/daemon/attention.go +5 -2
@@ -31,7 +31,7 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
3131 tb.attention = true
3232 for _, attachment := range sess.snapshotAttachmentsLocked() {
3333 view := attachment.viewSnapshot()
34- if domain.TabStableID(tb.stableID) == view.tabID {
34+ if domain.TabStableID(tb.stableID) == view.tabID && attachment.terminalFocus().MaySee() {
3535 tb.attentionVisiblePaint = true
3636 break
3737 }
@@ -232,8 +232,11 @@ func (d *Daemon) repaintAllAttachedClients() {
232232 }
233233 }
234234
235+// ackAttention clears the bell of the tab ac shows. A client whose terminal
236+// window reported losing focus is not looked at, so it never clears a bell:
237+// the bell stays for every client until one that may be seen shows the tab.
235238 func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool {
236- if tb == nil || ac == nil {
239+ if tb == nil || ac == nil || !ac.terminalFocus().MaySee() {
237240 return false
238241 }
239242 s.mu.Lock()
@@ -31,7 +31,7 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
31 tb.attention = true31 tb.attention = true
32 for _, attachment := range sess.snapshotAttachmentsLocked() {32 for _, attachment := range sess.snapshotAttachmentsLocked() {
33 view := attachment.viewSnapshot()33 view := attachment.viewSnapshot()
34- if domain.TabStableID(tb.stableID) == view.tabID {34+ if domain.TabStableID(tb.stableID) == view.tabID && attachment.terminalFocus().MaySee() {
35 tb.attentionVisiblePaint = true35 tb.attentionVisiblePaint = true
36 break36 break
37 }37 }
@@ -232,8 +232,11 @@ func (d *Daemon) repaintAllAttachedClients() {
232 }232 }
233 }233 }
234 234
235+// ackAttention clears the bell of the tab ac shows. A client whose terminal
236+// window reported losing focus is not looked at, so it never clears a bell:
237+// the bell stays for every client until one that may be seen shows the tab.
235 func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool {238 func (s *session) ackAttention(tb *tab, ac *attachedClient, visible bool) bool {
236- if tb == nil || ac == nil {239+ if tb == nil || ac == nil || !ac.terminalFocus().MaySee() {
237 return false240 return false
238 }241 }
239 s.mu.Lock()242 s.mu.Lock()
M internal/usecase/daemon/client.go +6 -0
@@ -69,6 +69,10 @@ type attachedClient struct {
6969 resumeClaimToken uint64
7070 parked bool
7171 echoAck atomic.Uint64
72+ // focus holds the domain.TerminalFocus the client last reported. It is an
73+ // atomic so any daemon path reads it without taking a lock; see
74+ // terminalFocus.
75+ focus atomic.Uint32
7276 // prepareFailureFallback prevents a direct fallback paint from recursively
7377 // reporting the same failed prepare through its notice repaint. It is only
7478 // needed while no render coordinator is installed.
@@ -636,6 +640,7 @@ type attachClientOptions struct {
636640 terminalCapabilities terminalcap.Capabilities
637641 capabilitiesSet bool
638642 navigationCapabilities protocol.NavigationCapabilities
643+ terminalFocus domain.TerminalFocus
639644 }
640645
641646 func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
@@ -717,6 +722,7 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
717722 resumeCapable: opts.resumeCapable,
718723 resumeToken: resumeToken,
719724 }
725+ ac.setTerminalFocus(opts.terminalFocus)
720726 output.attachment = ac
721727 ac.initOverlays()
722728 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
@@ -69,6 +69,10 @@ type attachedClient struct {
69 resumeClaimToken uint6469 resumeClaimToken uint64
70 parked bool70 parked bool
71 echoAck atomic.Uint6471 echoAck atomic.Uint64
72+ // focus holds the domain.TerminalFocus the client last reported. It is an
73+ // atomic so any daemon path reads it without taking a lock; see
74+ // terminalFocus.
75+ focus atomic.Uint32
72 // prepareFailureFallback prevents a direct fallback paint from recursively76 // prepareFailureFallback prevents a direct fallback paint from recursively
73 // reporting the same failed prepare through its notice repaint. It is only77 // reporting the same failed prepare through its notice repaint. It is only
74 // needed while no render coordinator is installed.78 // needed while no render coordinator is installed.
@@ -636,6 +640,7 @@ type attachClientOptions struct {
636 terminalCapabilities terminalcap.Capabilities640 terminalCapabilities terminalcap.Capabilities
637 capabilitiesSet bool641 capabilitiesSet bool
638 navigationCapabilities protocol.NavigationCapabilities642 navigationCapabilities protocol.NavigationCapabilities
643+ terminalFocus domain.TerminalFocus
639 }644 }
640 645
641 func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {646 func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
@@ -717,6 +722,7 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
717 resumeCapable: opts.resumeCapable,722 resumeCapable: opts.resumeCapable,
718 resumeToken: resumeToken,723 resumeToken: resumeToken,
719 }724 }
725+ ac.setTerminalFocus(opts.terminalFocus)
720 output.attachment = ac726 output.attachment = ac
721 ac.initOverlays()727 ac.initOverlays()
722 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)728 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
M internal/usecase/daemon/client_frame_routing.go +2 -0
@@ -186,6 +186,8 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi
186186 d.switchSamePeerForAttachment(effect, message)
187187 case protocol.SelectTab:
188188 d.selectTabForAttachment(effect, message)
189+ case protocol.TerminalFocus:
190+ d.applyTerminalFocusForAttachment(effect, message)
189191 case protocol.RecentRouteSnapshot:
190192 replayIdentity := effect.ac.setRouteSnapshot(message)
191193 d.pokeAttentionTicker()
@@ -186,6 +186,8 @@ func (d *Daemon) handleActiveAttachmentClientMessage(capability attachmentCapabi
186 d.switchSamePeerForAttachment(effect, message)186 d.switchSamePeerForAttachment(effect, message)
187 case protocol.SelectTab:187 case protocol.SelectTab:
188 d.selectTabForAttachment(effect, message)188 d.selectTabForAttachment(effect, message)
189+ case protocol.TerminalFocus:
190+ d.applyTerminalFocusForAttachment(effect, message)
189 case protocol.RecentRouteSnapshot:191 case protocol.RecentRouteSnapshot:
190 replayIdentity := effect.ac.setRouteSnapshot(message)192 replayIdentity := effect.ac.setRouteSnapshot(message)
191 d.pokeAttentionTicker()193 d.pokeAttentionTicker()
M internal/usecase/daemon/daemon.go +1 -0
@@ -1506,6 +1506,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
15061506 resumeCapable: true,
15071507 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
15081508 navigationCapabilities: h.NavigationCapabilities,
1509+ terminalFocus: h.TerminalFocus,
15091510 terminalCapabilities: terminalCapabilities,
15101511 capabilitiesSet: true,
15111512 }
@@ -1506,6 +1506,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
1506 resumeCapable: true,1506 resumeCapable: true,
1507 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),1507 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1508 navigationCapabilities: h.NavigationCapabilities,1508 navigationCapabilities: h.NavigationCapabilities,
1509+ terminalFocus: h.TerminalFocus,
1509 terminalCapabilities: terminalCapabilities,1510 terminalCapabilities: terminalCapabilities,
1510 capabilitiesSet: true,1511 capabilitiesSet: true,
1511 }1512 }
M internal/usecase/daemon/resume.go +2 -0
@@ -667,6 +667,8 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection,
667667 // direct-terminal capability before resumed capture or first paint observes
668668 // the replacement transport.
669669 d.reconfigureAttachmentOutput(sess, ac, h)
670+ // The replacement terminal's focus, too, is declared before first paint.
671+ ac.setTerminalFocus(h.TerminalFocus)
670672 geometry := h.Geometry()
671673 if geometry.Size != sz {
672674 geometry = domain.Geometry{Size: sz}
@@ -667,6 +667,8 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection,
667 // direct-terminal capability before resumed capture or first paint observes667 // direct-terminal capability before resumed capture or first paint observes
668 // the replacement transport.668 // the replacement transport.
669 d.reconfigureAttachmentOutput(sess, ac, h)669 d.reconfigureAttachmentOutput(sess, ac, h)
670+ // The replacement terminal's focus, too, is declared before first paint.
671+ ac.setTerminalFocus(h.TerminalFocus)
670 geometry := h.Geometry()672 geometry := h.Geometry()
671 if geometry.Size != sz {673 if geometry.Size != sz {
672 geometry = domain.Geometry{Size: sz}674 geometry = domain.Geometry{Size: sz}
M internal/usecase/daemon/status.go +4 -1
@@ -206,6 +206,9 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo
206206 name := formatRecentRouteName(presentation)
207207 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
208208 activeIndex := 0
209+ // An unfocused client leaves the bell of its own tab pending, so it shows
210+ // it like a background tab's bell.
211+ seen := ac.terminalFocus().MaySee()
209212 if ac != nil {
210213 view := ac.viewSnapshot()
211214 for i, tb := range s.tabs {
@@ -218,7 +221,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo
218221 for i, tb := range s.tabs {
219222 name := tabDisplayName(tb, i)
220223 active := i == activeIndex
221- attention := tb.attention && (!active || tb.attentionVisiblePaint)
224+ attention := tb.attention && (!active || !seen || tb.attentionVisiblePaint)
222225 snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention}
223226 }
224227 return snap
@@ -206,6 +206,9 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo
206 name := formatRecentRouteName(presentation)206 name := formatRecentRouteName(presentation)
207 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}207 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
208 activeIndex := 0208 activeIndex := 0
209+ // An unfocused client leaves the bell of its own tab pending, so it shows
210+ // it like a background tab's bell.
211+ seen := ac.terminalFocus().MaySee()
209 if ac != nil {212 if ac != nil {
210 view := ac.viewSnapshot()213 view := ac.viewSnapshot()
211 for i, tb := range s.tabs {214 for i, tb := range s.tabs {
@@ -218,7 +221,7 @@ func (s *session) statusSegmentsFor(ac *attachedClient, includeTerminalTitle boo
218 for i, tb := range s.tabs {221 for i, tb := range s.tabs {
219 name := tabDisplayName(tb, i)222 name := tabDisplayName(tb, i)
220 active := i == activeIndex223 active := i == activeIndex
221- attention := tb.attention && (!active || tb.attentionVisiblePaint)224+ attention := tb.attention && (!active || !seen || tb.attentionVisiblePaint)
222 snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention}225 snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention}
223 }226 }
224 return snap227 return snap
A internal/usecase/daemon/terminal_focus.go +65 -0
@@ -0,0 +1,65 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/protocol"
6+)
7+
8+// Terminal focus.
9+//
10+// Each attachment carries the focus its client's terminal window last
11+// reported. It is a lock-free read, so any daemon feature can ask whether a
12+// user may be looking at an attachment. An attachment that never reports keeps
13+// unknown focus, which counts as possibly seen, so terminals without focus
14+// reporting behave exactly as before.
15+//
16+// Attention uses it today: only an attachment that may be seen acknowledges a
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
19+// acknowledged by its next paint.
20+
21+// terminalFocus returns the focus the client last reported.
22+func (ac *attachedClient) terminalFocus() domain.TerminalFocus {
23+ if ac == nil {
24+ return domain.TerminalFocusUnknown
25+ }
26+ return domain.TerminalFocus(ac.focus.Load())
27+}
28+
29+// setTerminalFocus records a reported focus and reports whether it changed.
30+func (ac *attachedClient) setTerminalFocus(focus domain.TerminalFocus) bool {
31+ return domain.TerminalFocus(ac.focus.Swap(uint32(focus))) != focus
32+}
33+
34+// 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.
37+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 {
39+ return
40+ }
41+ if !message.Focus.MaySee() {
42+ effect.ac.setTerminalFocus(message.Focus)
43+ return
44+ }
45+ // Like a bell rung on a seen tab, the bell a focused window finds on its
46+ // tab is drawn for one pulse before a paint acknowledges it. Mark it before
47+ // the focus lets any paint acknowledge.
48+ effect.sess.markVisibleAttention(effect.ac)
49+ effect.ac.setTerminalFocus(message.Focus)
50+ d.invalidateRender(effect.sess, effect.ac, false, "terminal_focus.go")
51+}
52+
53+// markVisibleAttention asks for one visible bell pulse on the tab ac shows,
54+// when that tab is ringing.
55+func (s *session) markVisibleAttention(ac *attachedClient) {
56+ view := ac.viewSnapshot()
57+ s.mu.Lock()
58+ defer s.mu.Unlock()
59+ for _, tb := range s.tabs {
60+ if tb != nil && tb.attention && domain.TabStableID(tb.stableID) == view.tabID {
61+ tb.attentionVisiblePaint = true
62+ return
63+ }
64+ }
65+}
@@ -0,0 +1,65 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/protocol"
6+)
7+
8+// Terminal focus.
9+//
10+// Each attachment carries the focus its client's terminal window last
11+// reported. It is a lock-free read, so any daemon feature can ask whether a
12+// user may be looking at an attachment. An attachment that never reports keeps
13+// unknown focus, which counts as possibly seen, so terminals without focus
14+// reporting behave exactly as before.
15+//
16+// Attention uses it today: only an attachment that may be seen acknowledges a
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
19+// acknowledged by its next paint.
20+
21+// terminalFocus returns the focus the client last reported.
22+func (ac *attachedClient) terminalFocus() domain.TerminalFocus {
23+ if ac == nil {
24+ return domain.TerminalFocusUnknown
25+ }
26+ return domain.TerminalFocus(ac.focus.Load())
27+}
28+
29+// setTerminalFocus records a reported focus and reports whether it changed.
30+func (ac *attachedClient) setTerminalFocus(focus domain.TerminalFocus) bool {
31+ return domain.TerminalFocus(ac.focus.Swap(uint32(focus))) != focus
32+}
33+
34+// 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.
37+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 {
39+ return
40+ }
41+ if !message.Focus.MaySee() {
42+ effect.ac.setTerminalFocus(message.Focus)
43+ return
44+ }
45+ // Like a bell rung on a seen tab, the bell a focused window finds on its
46+ // tab is drawn for one pulse before a paint acknowledges it. Mark it before
47+ // the focus lets any paint acknowledge.
48+ effect.sess.markVisibleAttention(effect.ac)
49+ effect.ac.setTerminalFocus(message.Focus)
50+ d.invalidateRender(effect.sess, effect.ac, false, "terminal_focus.go")
51+}
52+
53+// markVisibleAttention asks for one visible bell pulse on the tab ac shows,
54+// when that tab is ringing.
55+func (s *session) markVisibleAttention(ac *attachedClient) {
56+ view := ac.viewSnapshot()
57+ s.mu.Lock()
58+ defer s.mu.Unlock()
59+ for _, tb := range s.tabs {
60+ if tb != nil && tb.attention && domain.TabStableID(tb.stableID) == view.tabID {
61+ tb.attentionVisiblePaint = true
62+ return
63+ }
64+ }
65+}
A internal/usecase/daemon/terminal_focus_test.go +131 -0
@@ -0,0 +1,131 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+ "github.com/bnema/vev/internal/usecase/ui"
11+)
12+
13+// TestPaintAcksAttentionOnlyWhenTheClientMaySeeIt pins the focus rule: a
14+// client whose terminal window lost focus shows the bell of its own tab but
15+// never clears it, while a focused client, or one whose terminal never
16+// reports focus, clears it on its visible paint as before.
17+func TestPaintAcksAttentionOnlyWhenTheClientMaySeeIt(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ focus domain.TerminalFocus
21+ wantAcked bool
22+ }{
23+ {name: "unknown focus acknowledges as before", focus: domain.TerminalFocusUnknown, wantAcked: true},
24+ {name: "focused client acknowledges", focus: domain.TerminalFocusFocused, wantAcked: true},
25+ {name: "unfocused client leaves the bell", focus: domain.TerminalFocusUnfocused, wantAcked: false},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
30+ defer releases[0]()
31+ selectTestAttachmentTab(sess, 0)
32+ ac.setTerminalFocus(tt.focus)
33+ d.noteAttention(sess, sess.tabs[0])
34+
35+ d.setAttentionFrame(1)
36+ d.paint(sess, ac, true, nil)
37+ require.Contains(t, string(mustOutputData(t, sends)), string(ui.AttentionGlyph), "the tab bar shows the bell")
38+
39+ sess.mu.Lock()
40+ defer sess.mu.Unlock()
41+ require.Equal(t, !tt.wantAcked, sess.tabs[0].attention)
42+ })
43+ }
44+}
45+
46+// TestRegainedFocusAcknowledgesTheVisibleTab pins the other half: the bell a
47+// hidden client left pending clears once that client's window is focused and
48+// paints again.
49+func TestRegainedFocusAcknowledgesTheVisibleTab(t *testing.T) {
50+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
51+ defer releases[0]()
52+ selectTestAttachmentTab(sess, 0)
53+ ac.setTerminalFocus(domain.TerminalFocusUnfocused)
54+ d.noteAttention(sess, sess.tabs[0])
55+ d.setAttentionFrame(1)
56+ d.paint(sess, ac, true, nil)
57+ mustOutputData(t, sends)
58+ require.True(t, sess.anyAttention(), "hidden client keeps the bell")
59+
60+ require.True(t, ac.setTerminalFocus(domain.TerminalFocusFocused))
61+ require.False(t, ac.setTerminalFocus(domain.TerminalFocusFocused), "a repeated report is no change")
62+ d.paint(sess, ac, true, nil)
63+ mustOutputData(t, sends)
64+ require.False(t, sess.anyAttention(), "focused client clears the bell")
65+}
66+
67+// TestAttachDeclaredFocusGuardsTheFirstPaint pins that the focus declared in
68+// Hello is the attachment's focus from its first paint, so a hidden window
69+// that opens an attachment never clears the bell of the tab it lands on.
70+func TestAttachDeclaredFocusGuardsTheFirstPaint(t *testing.T) {
71+ tests := []struct {
72+ name string
73+ focus domain.TerminalFocus
74+ wantAcked bool
75+ }{
76+ {name: "unknown focus acknowledges as before", focus: domain.TerminalFocusUnknown, wantAcked: true},
77+ {name: "focused window acknowledges", focus: domain.TerminalFocusFocused, wantAcked: true},
78+ {name: "unfocused window leaves the bell", focus: domain.TerminalFocusUnfocused, wantAcked: false},
79+ }
80+ for _, tt := range tests {
81+ t.Run(tt.name, func(t *testing.T) {
82+ d, sess, first, _, releases := newManualTabSession(t, 1)
83+ defer releases[0]()
84+ first.setTerminalFocus(domain.TerminalFocusUnfocused)
85+ d.noteAttention(sess, sess.tabs[0])
86+
87+ tr, sends := newCapturingTransport(t)
88+ peer, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{terminalFocus: tt.focus})
89+ require.NoError(t, err)
90+ require.Equal(t, tt.focus, peer.terminalFocus())
91+ require.True(t, sess.switchAttachmentTab(peer, 0))
92+ d.setAttentionFrame(1)
93+ d.paint(sess, peer, true, nil)
94+ mustOutputData(t, sends)
95+ require.Equal(t, !tt.wantAcked, sess.anyAttention())
96+ })
97+ }
98+}
99+
100+// TestFocusReportDrawsTheBellBeforeAcknowledging pins the routed focus report:
101+// only a gain of focus repaints, and the bell a focused window finds on its
102+// tab is drawn for one pulse before a later paint acknowledges it.
103+func TestFocusReportDrawsTheBellBeforeAcknowledging(t *testing.T) {
104+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
105+ defer releases[0]()
106+ selectTestAttachmentTab(sess, 0)
107+ effect := &attachmentEffect{attachmentCapability: attachmentCapability{sess: sess, ac: ac}}
108+
109+ d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusUnfocused})
110+ require.Equal(t, domain.TerminalFocusUnfocused, ac.terminalFocus())
111+ require.Empty(t, sends, "losing focus does not repaint")
112+ d.noteAttention(sess, sess.tabs[0])
113+
114+ d.setAttentionFrame(0)
115+ d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused})
116+ require.Equal(t, domain.TerminalFocusFocused, ac.terminalFocus())
117+ mustOutputData(t, sends)
118+ require.True(t, sess.anyAttention(), "the bell survives the blank pulse frame")
119+
120+ d.setAttentionFrame(1)
121+ d.paint(sess, ac, false, nil)
122+ require.Contains(t, string(mustOutputData(t, sends)), string(ui.AttentionGlyph), "the focused window draws the bell")
123+ require.False(t, sess.anyAttention(), "the drawn bell is acknowledged")
124+
125+ // The acknowledgement repaints every client; drain those frames.
126+ for len(sends) > 0 {
127+ <-sends
128+ }
129+ d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused})
130+ require.Empty(t, sends, "an unchanged focus does not repaint")
131+}
@@ -0,0 +1,131 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol"
10+ "github.com/bnema/vev/internal/usecase/ui"
11+)
12+
13+// TestPaintAcksAttentionOnlyWhenTheClientMaySeeIt pins the focus rule: a
14+// client whose terminal window lost focus shows the bell of its own tab but
15+// never clears it, while a focused client, or one whose terminal never
16+// reports focus, clears it on its visible paint as before.
17+func TestPaintAcksAttentionOnlyWhenTheClientMaySeeIt(t *testing.T) {
18+ tests := []struct {
19+ name string
20+ focus domain.TerminalFocus
21+ wantAcked bool
22+ }{
23+ {name: "unknown focus acknowledges as before", focus: domain.TerminalFocusUnknown, wantAcked: true},
24+ {name: "focused client acknowledges", focus: domain.TerminalFocusFocused, wantAcked: true},
25+ {name: "unfocused client leaves the bell", focus: domain.TerminalFocusUnfocused, wantAcked: false},
26+ }
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
30+ defer releases[0]()
31+ selectTestAttachmentTab(sess, 0)
32+ ac.setTerminalFocus(tt.focus)
33+ d.noteAttention(sess, sess.tabs[0])
34+
35+ d.setAttentionFrame(1)
36+ d.paint(sess, ac, true, nil)
37+ require.Contains(t, string(mustOutputData(t, sends)), string(ui.AttentionGlyph), "the tab bar shows the bell")
38+
39+ sess.mu.Lock()
40+ defer sess.mu.Unlock()
41+ require.Equal(t, !tt.wantAcked, sess.tabs[0].attention)
42+ })
43+ }
44+}
45+
46+// TestRegainedFocusAcknowledgesTheVisibleTab pins the other half: the bell a
47+// hidden client left pending clears once that client's window is focused and
48+// paints again.
49+func TestRegainedFocusAcknowledgesTheVisibleTab(t *testing.T) {
50+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
51+ defer releases[0]()
52+ selectTestAttachmentTab(sess, 0)
53+ ac.setTerminalFocus(domain.TerminalFocusUnfocused)
54+ d.noteAttention(sess, sess.tabs[0])
55+ d.setAttentionFrame(1)
56+ d.paint(sess, ac, true, nil)
57+ mustOutputData(t, sends)
58+ require.True(t, sess.anyAttention(), "hidden client keeps the bell")
59+
60+ require.True(t, ac.setTerminalFocus(domain.TerminalFocusFocused))
61+ require.False(t, ac.setTerminalFocus(domain.TerminalFocusFocused), "a repeated report is no change")
62+ d.paint(sess, ac, true, nil)
63+ mustOutputData(t, sends)
64+ require.False(t, sess.anyAttention(), "focused client clears the bell")
65+}
66+
67+// TestAttachDeclaredFocusGuardsTheFirstPaint pins that the focus declared in
68+// Hello is the attachment's focus from its first paint, so a hidden window
69+// that opens an attachment never clears the bell of the tab it lands on.
70+func TestAttachDeclaredFocusGuardsTheFirstPaint(t *testing.T) {
71+ tests := []struct {
72+ name string
73+ focus domain.TerminalFocus
74+ wantAcked bool
75+ }{
76+ {name: "unknown focus acknowledges as before", focus: domain.TerminalFocusUnknown, wantAcked: true},
77+ {name: "focused window acknowledges", focus: domain.TerminalFocusFocused, wantAcked: true},
78+ {name: "unfocused window leaves the bell", focus: domain.TerminalFocusUnfocused, wantAcked: false},
79+ }
80+ for _, tt := range tests {
81+ t.Run(tt.name, func(t *testing.T) {
82+ d, sess, first, _, releases := newManualTabSession(t, 1)
83+ defer releases[0]()
84+ first.setTerminalFocus(domain.TerminalFocusUnfocused)
85+ d.noteAttention(sess, sess.tabs[0])
86+
87+ tr, sends := newCapturingTransport(t)
88+ peer, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{terminalFocus: tt.focus})
89+ require.NoError(t, err)
90+ require.Equal(t, tt.focus, peer.terminalFocus())
91+ require.True(t, sess.switchAttachmentTab(peer, 0))
92+ d.setAttentionFrame(1)
93+ d.paint(sess, peer, true, nil)
94+ mustOutputData(t, sends)
95+ require.Equal(t, !tt.wantAcked, sess.anyAttention())
96+ })
97+ }
98+}
99+
100+// TestFocusReportDrawsTheBellBeforeAcknowledging pins the routed focus report:
101+// only a gain of focus repaints, and the bell a focused window finds on its
102+// tab is drawn for one pulse before a later paint acknowledges it.
103+func TestFocusReportDrawsTheBellBeforeAcknowledging(t *testing.T) {
104+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
105+ defer releases[0]()
106+ selectTestAttachmentTab(sess, 0)
107+ effect := &attachmentEffect{attachmentCapability: attachmentCapability{sess: sess, ac: ac}}
108+
109+ d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusUnfocused})
110+ require.Equal(t, domain.TerminalFocusUnfocused, ac.terminalFocus())
111+ require.Empty(t, sends, "losing focus does not repaint")
112+ d.noteAttention(sess, sess.tabs[0])
113+
114+ d.setAttentionFrame(0)
115+ d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused})
116+ require.Equal(t, domain.TerminalFocusFocused, ac.terminalFocus())
117+ mustOutputData(t, sends)
118+ require.True(t, sess.anyAttention(), "the bell survives the blank pulse frame")
119+
120+ d.setAttentionFrame(1)
121+ d.paint(sess, ac, false, nil)
122+ require.Contains(t, string(mustOutputData(t, sends)), string(ui.AttentionGlyph), "the focused window draws the bell")
123+ require.False(t, sess.anyAttention(), "the drawn bell is acknowledged")
124+
125+ // The acknowledgement repaints every client; drain those frames.
126+ for len(sends) > 0 {
127+ <-sends
128+ }
129+ d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused})
130+ require.Empty(t, sends, "an unchanged focus does not repaint")
131+}

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