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 Unverified
B5690EEEBB952194The 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. | |||
| 131 | 131 | `go tool buf generate`, and handle the new variant in `sessionwire`. | |
| 132 | 132 | Never edit generated `*.pb.go`; never add manual IDs or dispatch tables. | |
| 133 | 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 | 136 | ## Broker | |
| 137 | 137 | ||
| @@ -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 | ## Broker | 136 | ## Broker | ||
| 137 | 137 | ||||
M docs/configuration.md +3 -2
| @@ -283,7 +283,8 @@ Below 80 columns, popups (palette, picker, floating terminal, prompts) become fu | |||
| 283 | 283 | ||
| 284 | 284 | - The command runs on the daemon host every `bar.interval` (minimum `1s`), with the same environment as your panes. | |
| 285 | 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 | 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. | |
| @@ -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) { | |||
| 53 | 53 | KittyDirectGraphics: message.KittyDirectGraphics, | |
| 54 | 54 | KittyKeyboard: message.KittyKeyboard, | |
| 55 | 55 | TerminalFocus: uint32(message.TerminalFocus), | |
| 56 | + | ClientPid: message.ClientPID, | |
| 56 | 57 | }, nil | |
| 57 | 58 | } | |
| 58 | 59 | ||
| @@ -136,6 +137,7 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) { | |||
| 136 | 137 | hello.Remote = message.GetRemote() | |
| 137 | 138 | hello.KittyDirectGraphics = message.GetKittyDirectGraphics() | |
| 138 | 139 | hello.KittyKeyboard = message.GetKittyKeyboard() | |
| 140 | + | hello.ClientPID = message.GetClientPid() | |
| 139 | 141 | hello.TerminalFocus, err = enum8[domain.TerminalFocus](message.GetTerminalFocus()) | |
| 140 | 142 | if err != nil { | |
| 141 | 143 | 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 | }, nil | 57 | }, 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{}, err | 143 | return protocol.Hello{}, err | ||
M internal/adapters/sessionwire/proto_convert_test.go +1 -0
| @@ -39,6 +39,7 @@ func TestProtoClientRoundTrips(t *testing.T) { | |||
| 39 | 39 | messages := []protocol.ClientMessage{ | |
| 40 | 40 | protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}, | |
| 41 | 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 | 43 | protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")}, | |
| 43 | 44 | protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}}, | |
| 44 | 45 | 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 { | |||
| 611 | 611 | TermEnv: os.Getenv("TERM"), | |
| 612 | 612 | Cwd: currentWorkingDirectory(), | |
| 613 | 613 | TrueColor: terminalcap.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()), | |
| 614 | + | PID: uint32(os.Getpid()), | |
| 614 | 615 | } | |
| 615 | 616 | } | |
| 616 | 617 | ||
| @@ -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 ( | |||
| 16 | 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | |
| 17 | 17 | // negotiated ceilings and accepted daemon binding ride the same wire | |
| 18 | 18 | // version as the session and broker conversations. | |
| 19 | - | const Version uint16 = 63 | |
| 19 | + | const Version uint16 = 64 | |
| 20 | 20 | ||
| 21 | 21 | // HandshakeTimeout bounds every transport handshake from connect through the | |
| 22 | 22 | // first committed publication. It excludes the preceding client-local | |
| @@ -128,6 +128,9 @@ type Hello struct { | |||
| 128 | 128 | // TerminalFocus is the client's terminal window focus when it attaches, | |
| 129 | 129 | // so the attachment's first paint already knows whether it may be seen. | |
| 130 | 130 | 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 | 136 | func (h Hello) Geometry() domain.Geometry { | |
| @@ -433,7 +436,7 @@ func ValidateHello(h Hello) error { | |||
| 433 | 436 | if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil { | |
| 434 | 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 | 440 | return ErrInvalidHello | |
| 438 | 441 | } | |
| 439 | 442 | if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil { | |
| @@ -16,7 +16,7 @@ import ( | |||||
| 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | 16 | // daemonmux physical preamble conversation (multiplex.proto), whose | ||
| 17 | // negotiated ceilings and accepted daemon binding ride the same wire | 17 | // 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 = 63 | 19 | + | const Version uint16 = 64 |
| 20 | 20 | ||||
| 21 | // HandshakeTimeout bounds every transport handshake from connect through the | 21 | // HandshakeTimeout bounds every transport handshake from connect through the | ||
| 22 | // first committed publication. It excludes the preceding client-local | 22 | // 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.TerminalFocus | 130 | 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 ErrInvalidHello | 440 | 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 ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestVersionRemainsWireCompatible(t *testing.T) { | |
| 13 | - | require.Equal(t, uint16(63), Version) | |
| 13 | + | require.Equal(t, uint16(64), Version) | |
| 14 | 14 | require.Equal(t, (16<<20)-407, MaxOutputDataLen) | |
| 15 | 15 | } | |
| 16 | 16 | ||
| @@ -24,6 +24,8 @@ func TestHelloSemanticValidation(t *testing.T) { | |||
| 24 | 24 | {name: "valid", mutate: func(*Hello) {}}, | |
| 25 | 25 | {name: "unknown intent", mutate: func(h *Hello) { h.Intent = 99 }, want: ErrInvalidHello}, | |
| 26 | 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 | 29 | {name: "unsafe navigation", mutate: func(h *Hello) { h.NavigationCapabilities = 8 }, want: ErrInvalidHello}, | |
| 28 | 30 | {name: "exact target name mismatch", mutate: func(h *Hello) { | |
| 29 | 31 | 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 ( | |||
| 15 | 15 | // shares a payload type except route navigation failure, and the preamble | |
| 16 | 16 | // constants agree with the schema authority. | |
| 17 | 17 | 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) | |
| 20 | 20 | } | |
| 21 | 21 | if ProtocolEpoch != 1 { | |
| 22 | 22 | t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch) | |
| @@ -15,8 +15,8 @@ import ( | |||||
| 15 | // shares a payload type except route navigation failure, and the preamble | 15 | // 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 { | |||
| 37 | 37 | // Terminal window focus when the attachment opens: 0 = unknown, | |
| 38 | 38 | // 1 = focused, 2 = unfocused. Later changes travel as TerminalFocus. | |
| 39 | 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 | 44 | // 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 generated file: collapsed
M internal/usecase/client/attachment_session.go +5 -0
| @@ -74,6 +74,8 @@ type sessionAttachmentConfig struct { | |||
| 74 | 74 | TermEnv string | |
| 75 | 75 | Cwd string | |
| 76 | 76 | TrueColor bool | |
| 77 | + | // PID is this client's process ID; Hello carries it on local attaches. | |
| 78 | + | PID uint32 | |
| 77 | 79 | // Capabilities are the outer terminal's probed capabilities, declared in | |
| 78 | 80 | // Hello. | |
| 79 | 81 | Capabilities terminalCapabilities | |
| @@ -753,6 +755,9 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr | |||
| 753 | 755 | EnvironmentPolicy: environmentPolicy, | |
| 754 | 756 | Remote: !request.Local, | |
| 755 | 757 | } | |
| 758 | + | if request.Local { | |
| 759 | + | hello.ClientPID = w.cfg.PID | |
| 760 | + | } | |
| 756 | 761 | switch request.Admission { | |
| 757 | 762 | case ports.BrokerAdmissionExact: | |
| 758 | 763 | target := request.Target | |
| @@ -74,6 +74,8 @@ type sessionAttachmentConfig struct { | |||||
| 74 | TermEnv string | 74 | TermEnv string | ||
| 75 | Cwd string | 75 | Cwd string | ||
| 76 | TrueColor bool | 76 | 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 in | 79 | // Capabilities are the outer terminal's probed capabilities, declared in | ||
| 78 | // Hello. | 80 | // Hello. | ||
| 79 | Capabilities terminalCapabilities | 81 | 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.Target | 763 | target := request.Target | ||
M internal/usecase/client/supervisor.go +2 -0
| @@ -385,6 +385,8 @@ type AttachmentEnvironment struct { | |||
| 385 | 385 | TermEnv string | |
| 386 | 386 | Cwd string | |
| 387 | 387 | TrueColor bool | |
| 388 | + | // PID is this client's process ID, sent on local attaches only. | |
| 389 | + | PID uint32 | |
| 388 | 390 | // ProbeTerminal enables the one bounded capability probe of a real outer | |
| 389 | 391 | // terminal (Kitty graphics and keyboard). Virtual terminals leave it off. | |
| 390 | 392 | ProbeTerminal bool | |
| @@ -385,6 +385,8 @@ type AttachmentEnvironment struct { | |||||
| 385 | TermEnv string | 385 | TermEnv string | ||
| 386 | Cwd string | 386 | Cwd string | ||
| 387 | TrueColor bool | 387 | 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 outer | 390 | // 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 bool | 392 | ProbeTerminal bool | ||
M internal/usecase/client/supervisor_attach.go +1 -0
| @@ -325,6 +325,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest, | |||
| 325 | 325 | TermEnv: environment.TermEnv, | |
| 326 | 326 | Cwd: environment.Cwd, | |
| 327 | 327 | TrueColor: environment.TrueColor, | |
| 328 | + | PID: environment.PID, | |
| 328 | 329 | Capabilities: s.capabilities, | |
| 329 | 330 | SessionEnvironment: sessionEnv, | |
| 330 | 331 | 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 | |||
| 125 | 125 | attachments := sess.snapshotAttachmentsLocked() | |
| 126 | 126 | ctx.PaneCWD = sess.cwd | |
| 127 | 127 | sess.mu.Unlock() | |
| 128 | + | ctx.ClientPID = barClientPID(attachments) | |
| 128 | 129 | tb := sess.firstTab() | |
| 129 | 130 | if len(attachments) == 0 || tb == nil { | |
| 130 | 131 | return ctx, env, false | |
| @@ -148,6 +149,26 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip | |||
| 148 | 149 | 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 | 172 | // signalBarPollerReload wakes the poller without blocking. The channel is | |
| 152 | 173 | // buffered to one, so a pending signal already conveys "config changed". | |
| 153 | 174 | 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.cwd | 126 | 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, false | 131 | return ctx, env, false | ||
| @@ -148,6 +149,26 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip | |||||
| 148 | return ctx, env, true | 149 | 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 is | 172 | // 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 { | |||
| 27 | 27 | Pane string | |
| 28 | 28 | PaneCWD string | |
| 29 | 29 | 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 | 35 | func (c barScriptContext) env(base []string) []string { | |
| 33 | 36 | 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) | |
| 40 | 47 | } | |
| 41 | 48 | out := make([]string, 0, len(base)+len(vars)) | |
| 42 | 49 | for _, entry := range base { | |
| @@ -48,7 +55,7 @@ func (c barScriptContext) env(base []string) []string { | |||
| 48 | 55 | } | |
| 49 | 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 | 59 | out = append(out, key+"="+vars[key]) | |
| 53 | 60 | } | |
| 54 | 61 | return out | |
| @@ -27,16 +27,23 @@ type barScriptContext struct { | |||||
| 27 | Pane string | 27 | Pane string | ||
| 28 | PaneCWD string | 28 | PaneCWD string | ||
| 29 | Cols int | 29 | 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 out | 61 | return out | ||
M internal/usecase/daemon/bar_script_test.go +43 -7
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "testing" | |
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | 12 | "github.com/bnema/vev/internal/ports" | |
| 12 | 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 13 | 14 | "github.com/stretchr/testify/mock" | |
| @@ -50,17 +51,19 @@ func TestBarScriptContextEnv(t *testing.T) { | |||
| 50 | 51 | "VEV_PANE=old", | |
| 51 | 52 | "VEV_PANE_CWD=/old", | |
| 52 | 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 | 57 | got := ctx.env(base) | |
| 56 | 58 | ||
| 57 | 59 | 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", | |
| 64 | 67 | } | |
| 65 | 68 | seen := map[string]int{} | |
| 66 | 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 | 121 | func TestBarScriptRunner(t *testing.T) { | |
| 86 | 122 | t.Run("nil runner returns explicit error for command", func(t *testing.T) { | |
| 87 | 123 | 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 { | |||
| 73 | 73 | // atomic so any daemon path reads it without taking a lock; see | |
| 74 | 74 | // terminalFocus. | |
| 75 | 75 | 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 | 79 | // prepareFailureFallback prevents a direct fallback paint from recursively | |
| 77 | 80 | // reporting the same failed prepare through its notice repaint. It is only | |
| 78 | 81 | // needed while no render coordinator is installed. | |
| @@ -641,6 +644,7 @@ type attachClientOptions struct { | |||
| 641 | 644 | capabilitiesSet bool | |
| 642 | 645 | navigationCapabilities protocol.NavigationCapabilities | |
| 643 | 646 | terminalFocus domain.TerminalFocus | |
| 647 | + | clientPID uint32 | |
| 644 | 648 | } | |
| 645 | 649 | ||
| 646 | 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 | 727 | resumeToken: resumeToken, | |
| 724 | 728 | } | |
| 725 | 729 | ac.setTerminalFocus(opts.terminalFocus) | |
| 730 | + | ac.clientPID.Store(opts.clientPID) | |
| 726 | 731 | output.attachment = ac | |
| 727 | 732 | ac.initOverlays() | |
| 728 | 733 | 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; see | 73 | // atomic so any daemon path reads it without taking a lock; see | ||
| 74 | // terminalFocus. | 74 | // terminalFocus. | ||
| 75 | focus atomic.Uint32 | 75 | 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 recursively | 79 | // prepareFailureFallback prevents a direct fallback paint from recursively | ||
| 77 | // reporting the same failed prepare through its notice repaint. It is only | 80 | // 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 bool | 644 | capabilitiesSet bool | ||
| 642 | navigationCapabilities protocol.NavigationCapabilities | 645 | navigationCapabilities protocol.NavigationCapabilities | ||
| 643 | terminalFocus domain.TerminalFocus | 646 | 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 = ac | 731 | 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 | |||
| 1507 | 1507 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | |
| 1508 | 1508 | navigationCapabilities: h.NavigationCapabilities, | |
| 1509 | 1509 | terminalFocus: h.TerminalFocus, | |
| 1510 | + | clientPID: h.ClientPID, | |
| 1510 | 1511 | terminalCapabilities: terminalCapabilities, | |
| 1511 | 1512 | capabilitiesSet: true, | |
| 1512 | 1513 | } | |
| @@ -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, | |||
| 669 | 669 | d.reconfigureAttachmentOutput(sess, ac, h) | |
| 670 | 670 | // The replacement terminal's focus, too, is declared before first paint. | |
| 671 | 671 | ac.setTerminalFocus(h.TerminalFocus) | |
| 672 | + | ac.clientPID.Store(h.ClientPID) | |
| 672 | 673 | geometry := h.Geometry() | |
| 673 | 674 | if geometry.Size != sz { | |
| 674 | 675 | 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 @@ | |||
| 1 | 1 | #!/bin/sh | |
| 2 | 2 | ||
| 3 | 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 | 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 | 23 | now=$(date +%H:%M 2>/dev/null || true) | |
| 8 | 24 | ||
| @@ -39,9 +55,13 @@ if [ -r /proc/meminfo ]; then | |||
| 39 | 55 | ) | |
| 40 | 56 | fi | |
| 41 | 57 | ||
| 42 | - | line= | |
| 58 | + | line=$workspace | |
| 43 | 59 | 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 | 65 | fi | |
| 46 | 66 | if [ -n "$mem_pct" ]; then | |
| 47 | 67 | if [ -n "$line" ]; then | |
| @@ -1,8 +1,24 @@ | |||||
| 1 | #!/bin/sh | 1 | #!/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 | fi | 56 | fi | ||
| 41 | 57 | ||||
| 42 | - | line= | 58 | + | line=$workspace |
| 43 | if [ -n "$cpu_pct" ]; then | 59 | 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 | fi | 65 | fi | ||
| 46 | if [ -n "$mem_pct" ]; then | 66 | if [ -n "$mem_pct" ]; then | ||
| 47 | if [ -n "$line" ]; then | 67 | if [ -n "$line" ]; then | ||