detached 12ae076

feat(bar): expose local client PID to bar scripts as VEV_CLIENT_PID (#297)

* feat(bar): expose local client PID to bar scripts as VEV_CLIENT_PID

Closes #286

* fix(protocol): bump wire version for Hello client_pid and relax bar script state check
commit
12ae076170c09221fa2ac8c1928334f1b4d436c0
author
bnema · · 2026-09-26 19:33 UTC
committer
GitHub · · 2026-09-26 19:33 UTC
parent
cd9b41f
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.4.0
20 files · +149 -27
M docs/architecture.md +1 -1
@@ -131,7 +131,7 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
131131 `go tool buf generate`, and handle the new variant in `sessionwire`.
132132 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.
133133 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.
134-- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `59`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.
134+- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `64`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.
135135
136136 ## Broker
137137
@@ -131,7 +131,7 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
131 `go tool buf generate`, and handle the new variant in `sessionwire`.131 `go tool buf generate`, and handle the new variant in `sessionwire`.
132 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.132 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.
133 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.133 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.
134-- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `59`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.134+- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `64`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.
135 135
136 ## Broker136 ## Broker
137 137
M docs/configuration.md +3 -2
@@ -283,7 +283,8 @@ Below 80 columns, popups (palette, picker, floating terminal, prompts) become fu
283283
284284 - The command runs on the daemon host every `bar.interval` (minimum `1s`), with the same environment as your panes.
285285 - vev shows the first line of output, without colors. On failure it keeps the last good value.
286-- Scripts receive `VEV_ANCHOR`, `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`.
286+- Scripts receive `VEV_ANCHOR`, `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, `VEV_COLS`, and `VEV_CLIENT_PID`.
287+- `VEV_CLIENT_PID` is the PID of a local attached client, so a script can find the terminal window and monitor showing the bar. With several clients attached, it is the one whose terminal has focus, else the first local one. It is empty when only remote clients (SSH or another machine) are attached, since their process lives elsewhere.
287288 - Failures are logged with the exit code and stderr. Exit 127 means "command not found".
288289
289-Release installs ship two example scripts: `vev-bar-top-right` and `vev-bar-bottom-right`. The second one runs `git status` on every refresh; raise the interval on large repositories.
290+Release installs ship two example scripts: `vev-bar-top-right` and `vev-bar-bottom-right`. Under the NeferWL compositor, the first one also shows the client's monitor and workspaces (`DP-2 [x][ ][ ]`); set `bar.interval = 1s` to follow workspace changes. The second one runs `git status` on every refresh; raise the interval on large repositories.
@@ -283,7 +283,8 @@ Below 80 columns, popups (palette, picker, floating terminal, prompts) become fu
283 283
284 - The command runs on the daemon host every `bar.interval` (minimum `1s`), with the same environment as your panes.284 - The command runs on the daemon host every `bar.interval` (minimum `1s`), with the same environment as your panes.
285 - vev shows the first line of output, without colors. On failure it keeps the last good value.285 - vev shows the first line of output, without colors. On failure it keeps the last good value.
286-- Scripts receive `VEV_ANCHOR`, `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, and `VEV_COLS`.286+- Scripts receive `VEV_ANCHOR`, `VEV_SESSION`, `VEV_TAB`, `VEV_PANE`, `VEV_PANE_CWD`, `VEV_COLS`, and `VEV_CLIENT_PID`.
287+- `VEV_CLIENT_PID` is the PID of a local attached client, so a script can find the terminal window and monitor showing the bar. With several clients attached, it is the one whose terminal has focus, else the first local one. It is empty when only remote clients (SSH or another machine) are attached, since their process lives elsewhere.
287 - Failures are logged with the exit code and stderr. Exit 127 means "command not found".288 - Failures are logged with the exit code and stderr. Exit 127 means "command not found".
288 289
289-Release installs ship two example scripts: `vev-bar-top-right` and `vev-bar-bottom-right`. The second one runs `git status` on every refresh; raise the interval on large repositories.290+Release installs ship two example scripts: `vev-bar-top-right` and `vev-bar-bottom-right`. Under the NeferWL compositor, the first one also shows the client's monitor and workspaces (`DP-2 [x][ ][ ]`); set `bar.interval = 1s` to follow workspace changes. The second one runs `git status` on every refresh; raise the interval on large repositories.
M internal/adapters/sessionwire/proto_convert_session.go +2 -0
@@ -53,6 +53,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) {
5353 KittyDirectGraphics: message.KittyDirectGraphics,
5454 KittyKeyboard: message.KittyKeyboard,
5555 TerminalFocus: uint32(message.TerminalFocus),
56+ ClientPid: message.ClientPID,
5657 }, nil
5758 }
5859
@@ -136,6 +137,7 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) {
136137 hello.Remote = message.GetRemote()
137138 hello.KittyDirectGraphics = message.GetKittyDirectGraphics()
138139 hello.KittyKeyboard = message.GetKittyKeyboard()
140+ hello.ClientPID = message.GetClientPid()
139141 hello.TerminalFocus, err = enum8[domain.TerminalFocus](message.GetTerminalFocus())
140142 if err != nil {
141143 return protocol.Hello{}, err
@@ -53,6 +53,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) {
53 KittyDirectGraphics: message.KittyDirectGraphics,53 KittyDirectGraphics: message.KittyDirectGraphics,
54 KittyKeyboard: message.KittyKeyboard,54 KittyKeyboard: message.KittyKeyboard,
55 TerminalFocus: uint32(message.TerminalFocus),55 TerminalFocus: uint32(message.TerminalFocus),
56+ ClientPid: message.ClientPID,
56 }, nil57 }, nil
57 }58 }
58 59
@@ -136,6 +137,7 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) {
136 hello.Remote = message.GetRemote()137 hello.Remote = message.GetRemote()
137 hello.KittyDirectGraphics = message.GetKittyDirectGraphics()138 hello.KittyDirectGraphics = message.GetKittyDirectGraphics()
138 hello.KittyKeyboard = message.GetKittyKeyboard()139 hello.KittyKeyboard = message.GetKittyKeyboard()
140+ hello.ClientPID = message.GetClientPid()
139 hello.TerminalFocus, err = enum8[domain.TerminalFocus](message.GetTerminalFocus())141 hello.TerminalFocus, err = enum8[domain.TerminalFocus](message.GetTerminalFocus())
140 if err != nil {142 if err != nil {
141 return protocol.Hello{}, err143 return protocol.Hello{}, err
M internal/adapters/sessionwire/proto_convert_test.go +1 -0
@@ -39,6 +39,7 @@ func TestProtoClientRoundTrips(t *testing.T) {
3939 messages := []protocol.ClientMessage{
4040 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}},
4141 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, KittyDirectGraphics: true, KittyKeyboard: true},
42+ protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, ClientPID: 4242},
4243 protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")},
4344 protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}},
4445 protocol.Detach{},
@@ -39,6 +39,7 @@ func TestProtoClientRoundTrips(t *testing.T) {
39 messages := []protocol.ClientMessage{39 messages := []protocol.ClientMessage{
40 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}},40 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}},
41 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, KittyDirectGraphics: true, KittyKeyboard: true},41 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, KittyDirectGraphics: true, KittyKeyboard: true},
42+ protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, ClientPID: 4242},
42 protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")},43 protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")},
43 protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}},44 protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}},
44 protocol.Detach{},45 protocol.Detach{},
M internal/app/broker_client.go +1 -0
@@ -611,6 +611,7 @@ func terminalAttachmentEnvironment() client.AttachmentEnvironment {
611611 TermEnv: os.Getenv("TERM"),
612612 Cwd: currentWorkingDirectory(),
613613 TrueColor: terminalcap.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),
614+ PID: uint32(os.Getpid()),
614615 }
615616 }
616617
@@ -611,6 +611,7 @@ func terminalAttachmentEnvironment() client.AttachmentEnvironment {
611 TermEnv: os.Getenv("TERM"),611 TermEnv: os.Getenv("TERM"),
612 Cwd: currentWorkingDirectory(),612 Cwd: currentWorkingDirectory(),
613 TrueColor: terminalcap.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),613 TrueColor: terminalcap.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),
614+ PID: uint32(os.Getpid()),
614 }615 }
615 }616 }
616 617
M internal/protocol/session.go +5 -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 = 63
19+const Version uint16 = 64
2020
2121 // HandshakeTimeout bounds every transport handshake from connect through the
2222 // first committed publication. It excludes the preceding client-local
@@ -128,6 +128,9 @@ type Hello struct {
128128 // TerminalFocus is the client's terminal window focus when it attaches,
129129 // so the attachment's first paint already knows whether it may be seen.
130130 TerminalFocus domain.TerminalFocus
131+ // ClientPID is the local client process, exposed to bar scripts. It is 0
132+ // for remote attaches, which run on another machine.
133+ ClientPID uint32
131134 }
132135
133136 func (h Hello) Geometry() domain.Geometry {
@@ -433,7 +436,7 @@ func ValidateHello(h Hello) error {
433436 if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {
434437 return fmt.Errorf("%w: geometry", ErrInvalidHello)
435438 }
436- if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() {
439+ if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || (h.Remote && h.ClientPID != 0) {
437440 return ErrInvalidHello
438441 }
439442 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 = 6319+const Version uint16 = 64
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
@@ -128,6 +128,9 @@ type Hello struct {
128 // TerminalFocus is the client's terminal window focus when it attaches,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.129 // so the attachment's first paint already knows whether it may be seen.
130 TerminalFocus domain.TerminalFocus130 TerminalFocus domain.TerminalFocus
131+ // ClientPID is the local client process, exposed to bar scripts. It is 0
132+ // for remote attaches, which run on another machine.
133+ ClientPID uint32
131 }134 }
132 135
133 func (h Hello) Geometry() domain.Geometry {136 func (h Hello) Geometry() domain.Geometry {
@@ -433,7 +436,7 @@ func ValidateHello(h Hello) error {
433 if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {436 if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {
434 return fmt.Errorf("%w: geometry", ErrInvalidHello)437 return fmt.Errorf("%w: geometry", ErrInvalidHello)
435 }438 }
436- if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() {439+ if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || (h.Remote && h.ClientPID != 0) {
437 return ErrInvalidHello440 return ErrInvalidHello
438 }441 }
439 if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil {442 if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil {
M internal/protocol/session_test.go +3 -1
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(63), Version)
13+ require.Equal(t, uint16(64), Version)
1414 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
1515 }
1616
@@ -24,6 +24,8 @@ func TestHelloSemanticValidation(t *testing.T) {
2424 {name: "valid", mutate: func(*Hello) {}},
2525 {name: "unknown intent", mutate: func(h *Hello) { h.Intent = 99 }, want: ErrInvalidHello},
2626 {name: "invalid geometry", mutate: func(h *Hello) { h.Size.Rows = 0 }, want: ErrInvalidHello},
27+ {name: "local client pid", mutate: func(h *Hello) { h.ClientPID = 4242 }},
28+ {name: "remote client pid", mutate: func(h *Hello) { h.Remote, h.ClientPID = true, 4242 }, want: ErrInvalidHello},
2729 {name: "unsafe navigation", mutate: func(h *Hello) { h.NavigationCapabilities = 8 }, want: ErrInvalidHello},
2830 {name: "exact target name mismatch", mutate: func(h *Hello) {
2931 h.ExactTarget = &ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "other"}
@@ -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(63), Version)13+ require.Equal(t, uint16(64), Version)
14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
15 }15 }
16 16
@@ -24,6 +24,8 @@ func TestHelloSemanticValidation(t *testing.T) {
24 {name: "valid", mutate: func(*Hello) {}},24 {name: "valid", mutate: func(*Hello) {}},
25 {name: "unknown intent", mutate: func(h *Hello) { h.Intent = 99 }, want: ErrInvalidHello},25 {name: "unknown intent", mutate: func(h *Hello) { h.Intent = 99 }, want: ErrInvalidHello},
26 {name: "invalid geometry", mutate: func(h *Hello) { h.Size.Rows = 0 }, want: ErrInvalidHello},26 {name: "invalid geometry", mutate: func(h *Hello) { h.Size.Rows = 0 }, want: ErrInvalidHello},
27+ {name: "local client pid", mutate: func(h *Hello) { h.ClientPID = 4242 }},
28+ {name: "remote client pid", mutate: func(h *Hello) { h.Remote, h.ClientPID = true, 4242 }, want: ErrInvalidHello},
27 {name: "unsafe navigation", mutate: func(h *Hello) { h.NavigationCapabilities = 8 }, want: ErrInvalidHello},29 {name: "unsafe navigation", mutate: func(h *Hello) { h.NavigationCapabilities = 8 }, want: ErrInvalidHello},
28 {name: "exact target name mismatch", mutate: func(h *Hello) {30 {name: "exact target name mismatch", mutate: func(h *Hello) {
29 h.ExactTarget = &ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "other"}31 h.ExactTarget = &ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "other"}
M internal/protocol/wire/envelope_inventory_test.go +2 -2
@@ -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 != 63 {
19- t.Fatalf("protocol.Version = %d, want 63", protocol.Version)
18+ if protocol.Version != 64 {
19+ t.Fatalf("protocol.Version = %d, want 64", protocol.Version)
2020 }
2121 if ProtocolEpoch != 1 {
2222 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
@@ -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 != 63 {18+ if protocol.Version != 64 {
19- t.Fatalf("protocol.Version = %d, want 63", protocol.Version)19+ t.Fatalf("protocol.Version = %d, want 64", 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)
M internal/protocol/wire/schema/session.proto +2 -0
@@ -37,6 +37,8 @@ message Hello {
3737 // Terminal window focus when the attachment opens: 0 = unknown,
3838 // 1 = focused, 2 = unfocused. Later changes travel as TerminalFocus.
3939 uint32 terminal_focus = 24;
40+ // PID of a local client process, for bar scripts. 0 when unknown or remote.
41+ uint32 client_pid = 25;
4042 }
4143
4244 // Welcome answers an accepted attach.
@@ -37,6 +37,8 @@ message Hello {
37 // Terminal window focus when the attachment opens: 0 = unknown,37 // Terminal window focus when the attachment opens: 0 = unknown,
38 // 1 = focused, 2 = unfocused. Later changes travel as TerminalFocus.38 // 1 = focused, 2 = unfocused. Later changes travel as TerminalFocus.
39 uint32 terminal_focus = 24;39 uint32 terminal_focus = 24;
40+ // PID of a local client process, for bar scripts. 0 when unknown or remote.
41+ uint32 client_pid = 25;
40 }42 }
41 43
42 // Welcome answers an accepted attach.44 // Welcome answers an accepted attach.
M internal/protocol/wire/session.pb.go +13 -2
@@ -52,6 +52,8 @@ type Hello struct {
5252 // Terminal window focus when the attachment opens: 0 = unknown,
5353 // 1 = focused, 2 = unfocused. Later changes travel as TerminalFocus.
5454 TerminalFocus uint32 `protobuf:"varint,24,opt,name=terminal_focus,json=terminalFocus,proto3" json:"terminal_focus,omitempty"`
55+ // PID of a local client process, for bar scripts. 0 when unknown or remote.
56+ ClientPid uint32 `protobuf:"varint,25,opt,name=client_pid,json=clientPid,proto3" json:"client_pid,omitempty"`
5557 unknownFields protoimpl.UnknownFields
5658 sizeCache protoimpl.SizeCache
5759 }
@@ -247,6 +249,13 @@ func (x *Hello) GetTerminalFocus() uint32 {
247249 return 0
248250 }
249251
252+func (x *Hello) GetClientPid() uint32 {
253+ if x != nil {
254+ return x.ClientPid
255+ }
256+ return 0
257+}
258+
250259 // Welcome answers an accepted attach.
251260 type Welcome struct {
252261 state protoimpl.MessageState `protogen:"open.v1"`
@@ -2297,7 +2306,7 @@ var File_session_proto protoreflect.FileDescriptor
22972306
22982307 const file_session_proto_rawDesc = "" +
22992308 "\n" +
2300- "\rsession.proto\x12\vvev.wire.v1\x1a\fcommon.proto\"\xcf\x06\n" +
2309+ "\rsession.proto\x12\vvev.wire.v1\x1a\fcommon.proto\"\xee\x06\n" +
23012310 "\x05Hello\x12\x18\n" +
23022311 "\aversion\x18\x01 \x01(\rR\aversion\x12\x16\n" +
23032312 "\x06intent\x18\x02 \x01(\rR\x06intent\x12\x1b\n" +
@@ -2324,7 +2333,9 @@ const file_session_proto_rawDesc = "" +
23242333 "\x15kitty_direct_graphics\x18\x15 \x01(\bR\x13kittyDirectGraphics\x12G\n" +
23252334 "\x0esession_target\x18\x16 \x01(\v2 .vev.wire.v1.SessionAttachTargetR\rsessionTarget\x12%\n" +
23262335 "\x0ekitty_keyboard\x18\x17 \x01(\bR\rkittyKeyboard\x12%\n" +
2327- "\x0eterminal_focus\x18\x18 \x01(\rR\rterminalFocusJ\x04\b\x0f\x10\x10R\rremote_target\"\x84\x02\n" +
2336+ "\x0eterminal_focus\x18\x18 \x01(\rR\rterminalFocus\x12\x1d\n" +
2337+ "\n" +
2338+ "client_pid\x18\x19 \x01(\rR\tclientPidJ\x04\b\x0f\x10\x10R\rremote_target\"\x84\x02\n" +
23282339 "\aWelcome\x12\x1d\n" +
23292340 "\n" +
23302341 "session_id\x18\x01 \x01(\tR\tsessionId\x12!\n" +
@@ -52,6 +52,8 @@ type Hello struct {
52 // Terminal window focus when the attachment opens: 0 = unknown,52 // Terminal window focus when the attachment opens: 0 = unknown,
53 // 1 = focused, 2 = unfocused. Later changes travel as TerminalFocus.53 // 1 = focused, 2 = unfocused. Later changes travel as TerminalFocus.
54 TerminalFocus uint32 `protobuf:"varint,24,opt,name=terminal_focus,json=terminalFocus,proto3" json:"terminal_focus,omitempty"`54 TerminalFocus uint32 `protobuf:"varint,24,opt,name=terminal_focus,json=terminalFocus,proto3" json:"terminal_focus,omitempty"`
55+ // PID of a local client process, for bar scripts. 0 when unknown or remote.
56+ ClientPid uint32 `protobuf:"varint,25,opt,name=client_pid,json=clientPid,proto3" json:"client_pid,omitempty"`
55 unknownFields protoimpl.UnknownFields57 unknownFields protoimpl.UnknownFields
56 sizeCache protoimpl.SizeCache58 sizeCache protoimpl.SizeCache
57 }59 }
@@ -247,6 +249,13 @@ func (x *Hello) GetTerminalFocus() uint32 {
247 return 0249 return 0
248 }250 }
249 251
252+func (x *Hello) GetClientPid() uint32 {
253+ if x != nil {
254+ return x.ClientPid
255+ }
256+ return 0
257+}
258+
250 // Welcome answers an accepted attach.259 // Welcome answers an accepted attach.
251 type Welcome struct {260 type Welcome struct {
252 state protoimpl.MessageState `protogen:"open.v1"`261 state protoimpl.MessageState `protogen:"open.v1"`
@@ -2297,7 +2306,7 @@ var File_session_proto protoreflect.FileDescriptor
2297 2306
2298 const file_session_proto_rawDesc = "" +2307 const file_session_proto_rawDesc = "" +
2299 "\n" +2308 "\n" +
2300- "\rsession.proto\x12\vvev.wire.v1\x1a\fcommon.proto\"\xcf\x06\n" +2309+ "\rsession.proto\x12\vvev.wire.v1\x1a\fcommon.proto\"\xee\x06\n" +
2301 "\x05Hello\x12\x18\n" +2310 "\x05Hello\x12\x18\n" +
2302 "\aversion\x18\x01 \x01(\rR\aversion\x12\x16\n" +2311 "\aversion\x18\x01 \x01(\rR\aversion\x12\x16\n" +
2303 "\x06intent\x18\x02 \x01(\rR\x06intent\x12\x1b\n" +2312 "\x06intent\x18\x02 \x01(\rR\x06intent\x12\x1b\n" +
@@ -2324,7 +2333,9 @@ const file_session_proto_rawDesc = "" +
2324 "\x15kitty_direct_graphics\x18\x15 \x01(\bR\x13kittyDirectGraphics\x12G\n" +2333 "\x15kitty_direct_graphics\x18\x15 \x01(\bR\x13kittyDirectGraphics\x12G\n" +
2325 "\x0esession_target\x18\x16 \x01(\v2 .vev.wire.v1.SessionAttachTargetR\rsessionTarget\x12%\n" +2334 "\x0esession_target\x18\x16 \x01(\v2 .vev.wire.v1.SessionAttachTargetR\rsessionTarget\x12%\n" +
2326 "\x0ekitty_keyboard\x18\x17 \x01(\bR\rkittyKeyboard\x12%\n" +2335 "\x0ekitty_keyboard\x18\x17 \x01(\bR\rkittyKeyboard\x12%\n" +
2327- "\x0eterminal_focus\x18\x18 \x01(\rR\rterminalFocusJ\x04\b\x0f\x10\x10R\rremote_target\"\x84\x02\n" +2336+ "\x0eterminal_focus\x18\x18 \x01(\rR\rterminalFocus\x12\x1d\n" +
2337+ "\n" +
2338+ "client_pid\x18\x19 \x01(\rR\tclientPidJ\x04\b\x0f\x10\x10R\rremote_target\"\x84\x02\n" +
2328 "\aWelcome\x12\x1d\n" +2339 "\aWelcome\x12\x1d\n" +
2329 "\n" +2340 "\n" +
2330 "session_id\x18\x01 \x01(\tR\tsessionId\x12!\n" +2341 "session_id\x18\x01 \x01(\tR\tsessionId\x12!\n" +
M internal/usecase/client/attachment_session.go +5 -0
@@ -74,6 +74,8 @@ type sessionAttachmentConfig struct {
7474 TermEnv string
7575 Cwd string
7676 TrueColor bool
77+ // PID is this client's process ID; Hello carries it on local attaches.
78+ PID uint32
7779 // Capabilities are the outer terminal's probed capabilities, declared in
7880 // Hello.
7981 Capabilities terminalCapabilities
@@ -753,6 +755,9 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
753755 EnvironmentPolicy: environmentPolicy,
754756 Remote: !request.Local,
755757 }
758+ if request.Local {
759+ hello.ClientPID = w.cfg.PID
760+ }
756761 switch request.Admission {
757762 case ports.BrokerAdmissionExact:
758763 target := request.Target
@@ -74,6 +74,8 @@ type sessionAttachmentConfig struct {
74 TermEnv string74 TermEnv string
75 Cwd string75 Cwd string
76 TrueColor bool76 TrueColor bool
77+ // PID is this client's process ID; Hello carries it on local attaches.
78+ PID uint32
77 // Capabilities are the outer terminal's probed capabilities, declared in79 // Capabilities are the outer terminal's probed capabilities, declared in
78 // Hello.80 // Hello.
79 Capabilities terminalCapabilities81 Capabilities terminalCapabilities
@@ -753,6 +755,9 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
753 EnvironmentPolicy: environmentPolicy,755 EnvironmentPolicy: environmentPolicy,
754 Remote: !request.Local,756 Remote: !request.Local,
755 }757 }
758+ if request.Local {
759+ hello.ClientPID = w.cfg.PID
760+ }
756 switch request.Admission {761 switch request.Admission {
757 case ports.BrokerAdmissionExact:762 case ports.BrokerAdmissionExact:
758 target := request.Target763 target := request.Target
M internal/usecase/client/supervisor.go +2 -0
@@ -385,6 +385,8 @@ type AttachmentEnvironment struct {
385385 TermEnv string
386386 Cwd string
387387 TrueColor bool
388+ // PID is this client's process ID, sent on local attaches only.
389+ PID uint32
388390 // ProbeTerminal enables the one bounded capability probe of a real outer
389391 // terminal (Kitty graphics and keyboard). Virtual terminals leave it off.
390392 ProbeTerminal bool
@@ -385,6 +385,8 @@ type AttachmentEnvironment struct {
385 TermEnv string385 TermEnv string
386 Cwd string386 Cwd string
387 TrueColor bool387 TrueColor bool
388+ // PID is this client's process ID, sent on local attaches only.
389+ PID uint32
388 // ProbeTerminal enables the one bounded capability probe of a real outer390 // ProbeTerminal enables the one bounded capability probe of a real outer
389 // terminal (Kitty graphics and keyboard). Virtual terminals leave it off.391 // terminal (Kitty graphics and keyboard). Virtual terminals leave it off.
390 ProbeTerminal bool392 ProbeTerminal bool
M internal/usecase/client/supervisor_attach.go +1 -0
@@ -325,6 +325,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
325325 TermEnv: environment.TermEnv,
326326 Cwd: environment.Cwd,
327327 TrueColor: environment.TrueColor,
328+ PID: environment.PID,
328329 Capabilities: s.capabilities,
329330 SessionEnvironment: sessionEnv,
330331 BeforeAttached: beforeAttached,
@@ -325,6 +325,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
325 TermEnv: environment.TermEnv,325 TermEnv: environment.TermEnv,
326 Cwd: environment.Cwd,326 Cwd: environment.Cwd,
327 TrueColor: environment.TrueColor,327 TrueColor: environment.TrueColor,
328+ PID: environment.PID,
328 Capabilities: s.capabilities,329 Capabilities: s.capabilities,
329 SessionEnvironment: sessionEnv,330 SessionEnvironment: sessionEnv,
330 BeforeAttached: beforeAttached,331 BeforeAttached: beforeAttached,
M internal/usecase/daemon/bar_refresh.go +21 -0
@@ -125,6 +125,7 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
125125 attachments := sess.snapshotAttachmentsLocked()
126126 ctx.PaneCWD = sess.cwd
127127 sess.mu.Unlock()
128+ ctx.ClientPID = barClientPID(attachments)
128129 tb := sess.firstTab()
129130 if len(attachments) == 0 || tb == nil {
130131 return ctx, env, false
@@ -148,6 +149,26 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
148149 return ctx, env, true
149150 }
150151
152+// barClientPID picks the local client a bar script should describe: the one
153+// whose terminal has focus, else the first local one. Remote clients carry no
154+// PID, so a session attached only remotely yields 0.
155+func barClientPID(attachments []*attachedClient) uint32 {
156+ var first uint32
157+ for _, ac := range attachments {
158+ pid := ac.clientPID.Load()
159+ if pid == 0 {
160+ continue
161+ }
162+ if ac.terminalFocus() == domain.TerminalFocusFocused {
163+ return pid
164+ }
165+ if first == 0 {
166+ first = pid
167+ }
168+ }
169+ return first
170+}
171+
151172 // signalBarPollerReload wakes the poller without blocking. The channel is
152173 // buffered to one, so a pending signal already conveys "config changed".
153174 func (d *Daemon) signalBarPollerReload() {
@@ -125,6 +125,7 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
125 attachments := sess.snapshotAttachmentsLocked()125 attachments := sess.snapshotAttachmentsLocked()
126 ctx.PaneCWD = sess.cwd126 ctx.PaneCWD = sess.cwd
127 sess.mu.Unlock()127 sess.mu.Unlock()
128+ ctx.ClientPID = barClientPID(attachments)
128 tb := sess.firstTab()129 tb := sess.firstTab()
129 if len(attachments) == 0 || tb == nil {130 if len(attachments) == 0 || tb == nil {
130 return ctx, env, false131 return ctx, env, false
@@ -148,6 +149,26 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
148 return ctx, env, true149 return ctx, env, true
149 }150 }
150 151
152+// barClientPID picks the local client a bar script should describe: the one
153+// whose terminal has focus, else the first local one. Remote clients carry no
154+// PID, so a session attached only remotely yields 0.
155+func barClientPID(attachments []*attachedClient) uint32 {
156+ var first uint32
157+ for _, ac := range attachments {
158+ pid := ac.clientPID.Load()
159+ if pid == 0 {
160+ continue
161+ }
162+ if ac.terminalFocus() == domain.TerminalFocusFocused {
163+ return pid
164+ }
165+ if first == 0 {
166+ first = pid
167+ }
168+ }
169+ return first
170+}
171+
151 // signalBarPollerReload wakes the poller without blocking. The channel is172 // signalBarPollerReload wakes the poller without blocking. The channel is
152 // buffered to one, so a pending signal already conveys "config changed".173 // buffered to one, so a pending signal already conveys "config changed".
153 func (d *Daemon) signalBarPollerReload() {174 func (d *Daemon) signalBarPollerReload() {
M internal/usecase/daemon/bar_script.go +14 -7
@@ -27,16 +27,23 @@ type barScriptContext struct {
2727 Pane string
2828 PaneCWD string
2929 Cols int
30+ // ClientPID is a local attached client, 0 when only remote clients are
31+ // attached. It is exported empty when 0.
32+ ClientPID uint32
3033 }
3134
3235 func (c barScriptContext) env(base []string) []string {
3336 vars := map[string]string{
34- "VEV_ANCHOR": c.Anchor,
35- "VEV_SESSION": c.Session,
36- "VEV_TAB": c.Tab,
37- "VEV_PANE": c.Pane,
38- "VEV_PANE_CWD": c.PaneCWD,
39- "VEV_COLS": strconv.Itoa(c.Cols),
37+ "VEV_ANCHOR": c.Anchor,
38+ "VEV_SESSION": c.Session,
39+ "VEV_TAB": c.Tab,
40+ "VEV_PANE": c.Pane,
41+ "VEV_PANE_CWD": c.PaneCWD,
42+ "VEV_COLS": strconv.Itoa(c.Cols),
43+ "VEV_CLIENT_PID": "",
44+ }
45+ if c.ClientPID != 0 {
46+ vars["VEV_CLIENT_PID"] = strconv.FormatUint(uint64(c.ClientPID), 10)
4047 }
4148 out := make([]string, 0, len(base)+len(vars))
4249 for _, entry := range base {
@@ -48,7 +55,7 @@ func (c barScriptContext) env(base []string) []string {
4855 }
4956 out = append(out, entry)
5057 }
51- for _, key := range []string{"VEV_ANCHOR", "VEV_SESSION", "VEV_TAB", "VEV_PANE", "VEV_PANE_CWD", "VEV_COLS"} {
58+ for _, key := range []string{"VEV_ANCHOR", "VEV_SESSION", "VEV_TAB", "VEV_PANE", "VEV_PANE_CWD", "VEV_COLS", "VEV_CLIENT_PID"} {
5259 out = append(out, key+"="+vars[key])
5360 }
5461 return out
@@ -27,16 +27,23 @@ type barScriptContext struct {
27 Pane string27 Pane string
28 PaneCWD string28 PaneCWD string
29 Cols int29 Cols int
30+ // ClientPID is a local attached client, 0 when only remote clients are
31+ // attached. It is exported empty when 0.
32+ ClientPID uint32
30 }33 }
31 34
32 func (c barScriptContext) env(base []string) []string {35 func (c barScriptContext) env(base []string) []string {
33 vars := map[string]string{36 vars := map[string]string{
34- "VEV_ANCHOR": c.Anchor,37+ "VEV_ANCHOR": c.Anchor,
35- "VEV_SESSION": c.Session,38+ "VEV_SESSION": c.Session,
36- "VEV_TAB": c.Tab,39+ "VEV_TAB": c.Tab,
37- "VEV_PANE": c.Pane,40+ "VEV_PANE": c.Pane,
38- "VEV_PANE_CWD": c.PaneCWD,41+ "VEV_PANE_CWD": c.PaneCWD,
39- "VEV_COLS": strconv.Itoa(c.Cols),42+ "VEV_COLS": strconv.Itoa(c.Cols),
43+ "VEV_CLIENT_PID": "",
44+ }
45+ if c.ClientPID != 0 {
46+ vars["VEV_CLIENT_PID"] = strconv.FormatUint(uint64(c.ClientPID), 10)
40 }47 }
41 out := make([]string, 0, len(base)+len(vars))48 out := make([]string, 0, len(base)+len(vars))
42 for _, entry := range base {49 for _, entry := range base {
@@ -48,7 +55,7 @@ func (c barScriptContext) env(base []string) []string {
48 }55 }
49 out = append(out, entry)56 out = append(out, entry)
50 }57 }
51- for _, key := range []string{"VEV_ANCHOR", "VEV_SESSION", "VEV_TAB", "VEV_PANE", "VEV_PANE_CWD", "VEV_COLS"} {58+ for _, key := range []string{"VEV_ANCHOR", "VEV_SESSION", "VEV_TAB", "VEV_PANE", "VEV_PANE_CWD", "VEV_COLS", "VEV_CLIENT_PID"} {
52 out = append(out, key+"="+vars[key])59 out = append(out, key+"="+vars[key])
53 }60 }
54 return out61 return out
M internal/usecase/daemon/bar_script_test.go +43 -7
@@ -8,6 +8,7 @@ import (
88 "testing"
99 "time"
1010
11+ "github.com/bnema/vev/internal/domain"
1112 "github.com/bnema/vev/internal/ports"
1213 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1314 "github.com/stretchr/testify/mock"
@@ -50,17 +51,19 @@ func TestBarScriptContextEnv(t *testing.T) {
5051 "VEV_PANE=old",
5152 "VEV_PANE_CWD=/old",
5253 "VEV_COLS=1",
54+ "VEV_CLIENT_PID=1",
5355 }
54- ctx := barScriptContext{Anchor: "top-right", Session: "work", Tab: "2", Pane: "pane-3", PaneCWD: "/repo", Cols: 120}
56+ ctx := barScriptContext{Anchor: "top-right", Session: "work", Tab: "2", Pane: "pane-3", PaneCWD: "/repo", Cols: 120, ClientPID: 4242}
5557 got := ctx.env(base)
5658
5759 want := map[string]string{
58- "VEV_ANCHOR": "top-right",
59- "VEV_SESSION": "work",
60- "VEV_TAB": "2",
61- "VEV_PANE": "pane-3",
62- "VEV_PANE_CWD": "/repo",
63- "VEV_COLS": "120",
60+ "VEV_ANCHOR": "top-right",
61+ "VEV_SESSION": "work",
62+ "VEV_TAB": "2",
63+ "VEV_PANE": "pane-3",
64+ "VEV_PANE_CWD": "/repo",
65+ "VEV_COLS": "120",
66+ "VEV_CLIENT_PID": "4242",
6467 }
6568 seen := map[string]int{}
6669 for _, entry := range got {
@@ -82,6 +85,39 @@ func TestBarScriptContextEnv(t *testing.T) {
8285 }
8386 }
8487
88+func TestBarScriptContextEnvEmptyClientPID(t *testing.T) {
89+ got := barScriptContext{}.env([]string{"VEV_CLIENT_PID=99"})
90+ if !slices.Equal(got[len(got)-1:], []string{"VEV_CLIENT_PID="}) || slices.Contains(got, "VEV_CLIENT_PID=99") {
91+ t.Fatalf("env = %v, want an empty VEV_CLIENT_PID", got)
92+ }
93+}
94+
95+func TestBarClientPID(t *testing.T) {
96+ client := func(pid uint32, focus domain.TerminalFocus) *attachedClient {
97+ ac := &attachedClient{}
98+ ac.clientPID.Store(pid)
99+ ac.setTerminalFocus(focus)
100+ return ac
101+ }
102+ remote := client(0, domain.TerminalFocusFocused)
103+ first := client(10, domain.TerminalFocusUnfocused)
104+ focused := client(20, domain.TerminalFocusFocused)
105+ for _, tc := range []struct {
106+ name string
107+ acs []*attachedClient
108+ want uint32
109+ }{
110+ {"none", nil, 0},
111+ {"remote only", []*attachedClient{remote}, 0},
112+ {"first local", []*attachedClient{remote, first}, 10},
113+ {"focused local wins", []*attachedClient{first, focused}, 20},
114+ } {
115+ if got := barClientPID(tc.acs); got != tc.want {
116+ t.Errorf("%s: got %d, want %d", tc.name, got, tc.want)
117+ }
118+ }
119+}
120+
85121 func TestBarScriptRunner(t *testing.T) {
86122 t.Run("nil runner returns explicit error for command", func(t *testing.T) {
87123 runner := barScriptRunner{}
@@ -8,6 +8,7 @@ import (
8 "testing"8 "testing"
9 "time"9 "time"
10 10
11+ "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
12 portsmocks "github.com/bnema/vev/internal/ports/mocks"13 portsmocks "github.com/bnema/vev/internal/ports/mocks"
13 "github.com/stretchr/testify/mock"14 "github.com/stretchr/testify/mock"
@@ -50,17 +51,19 @@ func TestBarScriptContextEnv(t *testing.T) {
50 "VEV_PANE=old",51 "VEV_PANE=old",
51 "VEV_PANE_CWD=/old",52 "VEV_PANE_CWD=/old",
52 "VEV_COLS=1",53 "VEV_COLS=1",
54+ "VEV_CLIENT_PID=1",
53 }55 }
54- ctx := barScriptContext{Anchor: "top-right", Session: "work", Tab: "2", Pane: "pane-3", PaneCWD: "/repo", Cols: 120}56+ ctx := barScriptContext{Anchor: "top-right", Session: "work", Tab: "2", Pane: "pane-3", PaneCWD: "/repo", Cols: 120, ClientPID: 4242}
55 got := ctx.env(base)57 got := ctx.env(base)
56 58
57 want := map[string]string{59 want := map[string]string{
58- "VEV_ANCHOR": "top-right",60+ "VEV_ANCHOR": "top-right",
59- "VEV_SESSION": "work",61+ "VEV_SESSION": "work",
60- "VEV_TAB": "2",62+ "VEV_TAB": "2",
61- "VEV_PANE": "pane-3",63+ "VEV_PANE": "pane-3",
62- "VEV_PANE_CWD": "/repo",64+ "VEV_PANE_CWD": "/repo",
63- "VEV_COLS": "120",65+ "VEV_COLS": "120",
66+ "VEV_CLIENT_PID": "4242",
64 }67 }
65 seen := map[string]int{}68 seen := map[string]int{}
66 for _, entry := range got {69 for _, entry := range got {
@@ -82,6 +85,39 @@ func TestBarScriptContextEnv(t *testing.T) {
82 }85 }
83 }86 }
84 87
88+func TestBarScriptContextEnvEmptyClientPID(t *testing.T) {
89+ got := barScriptContext{}.env([]string{"VEV_CLIENT_PID=99"})
90+ if !slices.Equal(got[len(got)-1:], []string{"VEV_CLIENT_PID="}) || slices.Contains(got, "VEV_CLIENT_PID=99") {
91+ t.Fatalf("env = %v, want an empty VEV_CLIENT_PID", got)
92+ }
93+}
94+
95+func TestBarClientPID(t *testing.T) {
96+ client := func(pid uint32, focus domain.TerminalFocus) *attachedClient {
97+ ac := &attachedClient{}
98+ ac.clientPID.Store(pid)
99+ ac.setTerminalFocus(focus)
100+ return ac
101+ }
102+ remote := client(0, domain.TerminalFocusFocused)
103+ first := client(10, domain.TerminalFocusUnfocused)
104+ focused := client(20, domain.TerminalFocusFocused)
105+ for _, tc := range []struct {
106+ name string
107+ acs []*attachedClient
108+ want uint32
109+ }{
110+ {"none", nil, 0},
111+ {"remote only", []*attachedClient{remote}, 0},
112+ {"first local", []*attachedClient{remote, first}, 10},
113+ {"focused local wins", []*attachedClient{first, focused}, 20},
114+ } {
115+ if got := barClientPID(tc.acs); got != tc.want {
116+ t.Errorf("%s: got %d, want %d", tc.name, got, tc.want)
117+ }
118+ }
119+}
120+
85 func TestBarScriptRunner(t *testing.T) {121 func TestBarScriptRunner(t *testing.T) {
86 t.Run("nil runner returns explicit error for command", func(t *testing.T) {122 t.Run("nil runner returns explicit error for command", func(t *testing.T) {
87 runner := barScriptRunner{}123 runner := barScriptRunner{}
M internal/usecase/daemon/client.go +5 -0
@@ -73,6 +73,9 @@ type attachedClient struct {
7373 // atomic so any daemon path reads it without taking a lock; see
7474 // terminalFocus.
7575 focus atomic.Uint32
76+ // clientPID is the local client process from Hello, 0 when remote. It is
77+ // atomic because a resume replaces it while bar scripts read it.
78+ clientPID atomic.Uint32
7679 // prepareFailureFallback prevents a direct fallback paint from recursively
7780 // reporting the same failed prepare through its notice repaint. It is only
7881 // needed while no render coordinator is installed.
@@ -641,6 +644,7 @@ type attachClientOptions struct {
641644 capabilitiesSet bool
642645 navigationCapabilities protocol.NavigationCapabilities
643646 terminalFocus domain.TerminalFocus
647+ clientPID uint32
644648 }
645649
646650 func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
@@ -723,6 +727,7 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
723727 resumeToken: resumeToken,
724728 }
725729 ac.setTerminalFocus(opts.terminalFocus)
730+ ac.clientPID.Store(opts.clientPID)
726731 output.attachment = ac
727732 ac.initOverlays()
728733 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
@@ -73,6 +73,9 @@ type attachedClient struct {
73 // atomic so any daemon path reads it without taking a lock; see73 // atomic so any daemon path reads it without taking a lock; see
74 // terminalFocus.74 // terminalFocus.
75 focus atomic.Uint3275 focus atomic.Uint32
76+ // clientPID is the local client process from Hello, 0 when remote. It is
77+ // atomic because a resume replaces it while bar scripts read it.
78+ clientPID atomic.Uint32
76 // prepareFailureFallback prevents a direct fallback paint from recursively79 // prepareFailureFallback prevents a direct fallback paint from recursively
77 // reporting the same failed prepare through its notice repaint. It is only80 // reporting the same failed prepare through its notice repaint. It is only
78 // needed while no render coordinator is installed.81 // needed while no render coordinator is installed.
@@ -641,6 +644,7 @@ type attachClientOptions struct {
641 capabilitiesSet bool644 capabilitiesSet bool
642 navigationCapabilities protocol.NavigationCapabilities645 navigationCapabilities protocol.NavigationCapabilities
643 terminalFocus domain.TerminalFocus646 terminalFocus domain.TerminalFocus
647+ clientPID uint32
644 }648 }
645 649
646 func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {650 func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
@@ -723,6 +727,7 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
723 resumeToken: resumeToken,727 resumeToken: resumeToken,
724 }728 }
725 ac.setTerminalFocus(opts.terminalFocus)729 ac.setTerminalFocus(opts.terminalFocus)
730+ ac.clientPID.Store(opts.clientPID)
726 output.attachment = ac731 output.attachment = ac
727 ac.initOverlays()732 ac.initOverlays()
728 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)733 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
M internal/usecase/daemon/daemon.go +1 -0
@@ -1507,6 +1507,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
15071507 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
15081508 navigationCapabilities: h.NavigationCapabilities,
15091509 terminalFocus: h.TerminalFocus,
1510+ clientPID: h.ClientPID,
15101511 terminalCapabilities: terminalCapabilities,
15111512 capabilitiesSet: true,
15121513 }
@@ -1507,6 +1507,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
1507 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),1507 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1508 navigationCapabilities: h.NavigationCapabilities,1508 navigationCapabilities: h.NavigationCapabilities,
1509 terminalFocus: h.TerminalFocus,1509 terminalFocus: h.TerminalFocus,
1510+ clientPID: h.ClientPID,
1510 terminalCapabilities: terminalCapabilities,1511 terminalCapabilities: terminalCapabilities,
1511 capabilitiesSet: true,1512 capabilitiesSet: true,
1512 }1513 }
M internal/usecase/daemon/resume.go +1 -0
@@ -669,6 +669,7 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection,
669669 d.reconfigureAttachmentOutput(sess, ac, h)
670670 // The replacement terminal's focus, too, is declared before first paint.
671671 ac.setTerminalFocus(h.TerminalFocus)
672+ ac.clientPID.Store(h.ClientPID)
672673 geometry := h.Geometry()
673674 if geometry.Size != sz {
674675 geometry = domain.Geometry{Size: sz}
@@ -669,6 +669,7 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection,
669 d.reconfigureAttachmentOutput(sess, ac, h)669 d.reconfigureAttachmentOutput(sess, ac, h)
670 // The replacement terminal's focus, too, is declared before first paint.670 // The replacement terminal's focus, too, is declared before first paint.
671 ac.setTerminalFocus(h.TerminalFocus)671 ac.setTerminalFocus(h.TerminalFocus)
672+ ac.clientPID.Store(h.ClientPID)
672 geometry := h.Geometry()673 geometry := h.Geometry()
673 if geometry.Size != sz {674 if geometry.Size != sz {
674 geometry = domain.Geometry{Size: sz}675 geometry = domain.Geometry{Size: sz}
M scripts/vev-bar-top-right +23 -3
@@ -1,8 +1,24 @@
11 #!/bin/sh
22
33 # Print a compact, single-line system summary for vev's top-right bar anchor.
4-# When available, segments are ordered as CPU, memory, then clock.
4+# When available, segments are ordered as workspace, CPU, memory, then clock.
55 # Segments that cannot be read are omitted without producing stderr.
6+#
7+# The workspace segment needs the NeferWL compositor and a local client
8+# (VEV_CLIENT_PID set), plus jq. It shows the client's monitor and its workspaces,
9+# e.g. "DP-2 [x][ ][ ]". A remote client or another compositor omits it.
10+
11+workspace=
12+if [ -n "${VEV_CLIENT_PID:-}" ] && command -v neferwl >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then
13+ workspace=$(
14+ neferwl state output-of "$VEV_CLIENT_PID" 2>/dev/null |
15+ jq -r '
16+ if .workspace != "" and .active == 0 then "\(.name) \(.workspace)"
17+ else "\(.name) " + ([range(1; .count + 1) as $i | if $i == .active then "[x]" else "[ ]" end] | join(""))
18+ end
19+ ' 2>/dev/null || true
20+ )
21+fi
622
723 now=$(date +%H:%M 2>/dev/null || true)
824
@@ -39,9 +55,13 @@ if [ -r /proc/meminfo ]; then
3955 )
4056 fi
4157
42-line=
58+line=$workspace
4359 if [ -n "$cpu_pct" ]; then
44- line="C $cpu_pct%"
60+ if [ -n "$line" ]; then
61+ line="$line C $cpu_pct%"
62+ else
63+ line="C $cpu_pct%"
64+ fi
4565 fi
4666 if [ -n "$mem_pct" ]; then
4767 if [ -n "$line" ]; then
@@ -1,8 +1,24 @@
1 #!/bin/sh1 #!/bin/sh
2 2
3 # Print a compact, single-line system summary for vev's top-right bar anchor.3 # Print a compact, single-line system summary for vev's top-right bar anchor.
4-# When available, segments are ordered as CPU, memory, then clock.4+# When available, segments are ordered as workspace, CPU, memory, then clock.
5 # Segments that cannot be read are omitted without producing stderr.5 # Segments that cannot be read are omitted without producing stderr.
6+#
7+# The workspace segment needs the NeferWL compositor and a local client
8+# (VEV_CLIENT_PID set), plus jq. It shows the client's monitor and its workspaces,
9+# e.g. "DP-2 [x][ ][ ]". A remote client or another compositor omits it.
10+
11+workspace=
12+if [ -n "${VEV_CLIENT_PID:-}" ] && command -v neferwl >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then
13+ workspace=$(
14+ neferwl state output-of "$VEV_CLIENT_PID" 2>/dev/null |
15+ jq -r '
16+ if .workspace != "" and .active == 0 then "\(.name) \(.workspace)"
17+ else "\(.name) " + ([range(1; .count + 1) as $i | if $i == .active then "[x]" else "[ ]" end] | join(""))
18+ end
19+ ' 2>/dev/null || true
20+ )
21+fi
6 22
7 now=$(date +%H:%M 2>/dev/null || true)23 now=$(date +%H:%M 2>/dev/null || true)
8 24
@@ -39,9 +55,13 @@ if [ -r /proc/meminfo ]; then
39 )55 )
40 fi56 fi
41 57
42-line=58+line=$workspace
43 if [ -n "$cpu_pct" ]; then59 if [ -n "$cpu_pct" ]; then
44- line="C $cpu_pct%"60+ if [ -n "$line" ]; then
61+ line="$line C $cpu_pct%"
62+ else
63+ line="C $cpu_pct%"
64+ fi
45 fi65 fi
46 if [ -n "$mem_pct" ]; then66 if [ -n "$mem_pct" ]; then
47 if [ -n "$line" ]; then67 if [ -n "$line" ]; then

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