detached 60d0c9f

feat(picker): problem-only status dots and typed client notices (#293)

* refactor(client): route every client toast through domain.Notification

Client toasts now carry a typed code and severity and are keyed by
Notification.Subject, so a newer notice about the same subject replaces
the visible one. Remote host transitions are judged by the new
domain.RemoteHealthNotice, which also notifies recoveries.

* feat(picker): show status dots only for problems

Healthy and stopped rows no longer carry a dot. The grouped section header
now carries its host's problem dot (down, stale, version, no daemon), and a
session carries a red dot only when broken.

* fix(picker): avoid duplicate and unexplained problem dots

The section dot is dropped when a host row already shows it, and the local
section never shows a transient stale dot. Recovery is still reported across
an unknown in-between state, and a daemon starting on a no-daemon host is not
reported as a reconnection.

* feat(client): color client toast borders by severity

Error toasts get a red border and warnings a yellow one, using the same
fixed xterm-256 colors as the picker problem dots. Info keeps the default.

* refactor(client): keep remote notice policy in domain and text in client

domain.RemoteHealthTransition now owns the whole transition rule, including
keeping the last settled state across unknown observations and noticing a
failure-class change. User-facing wording moves back to the client, which also
gets one RemoteAvailability.Failing list, shared problem colors, a renamed
client toast renderer, a wide-rune width fix, and a separate handoff subject.

* test(client): cover remote host notice text, severity and scope
commit
60d0c9ff2d4199dfdff04b87997db2e6050e8a27
author
bnema · · 2026-09-26 06:20 UTC
committer
GitHub · · 2026-09-26 06:20 UTC
parent
5334f33
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
22 files · +608 -203
M internal/app/broker_client.go +2 -2
@@ -310,10 +310,10 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
310310 cfg.OnLifecycle(notice)
311311 }
312312 if notice.Kind == client.LifecycleNoticeBrokerLost {
313- picker.OfferNotice("broker-lost", "Connection lost; retrying…")
313+ picker.Notify(domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "Connection lost; retrying…"})
314314 }
315315 if notice.Kind == client.LifecycleNoticeBrokerReconnected {
316- picker.OfferNotice("broker-reconnected", "Connection restored")
316+ picker.Notify(domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeInfo, Message: "Connection restored"})
317317 }
318318 },
319319 Spinner: presentation,
@@ -310,10 +310,10 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
310 cfg.OnLifecycle(notice)310 cfg.OnLifecycle(notice)
311 }311 }
312 if notice.Kind == client.LifecycleNoticeBrokerLost {312 if notice.Kind == client.LifecycleNoticeBrokerLost {
313- picker.OfferNotice("broker-lost", "Connection lost; retrying…")313+ picker.Notify(domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "Connection lost; retrying…"})
314 }314 }
315 if notice.Kind == client.LifecycleNoticeBrokerReconnected {315 if notice.Kind == client.LifecycleNoticeBrokerReconnected {
316- picker.OfferNotice("broker-reconnected", "Connection restored")316+ picker.Notify(domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeInfo, Message: "Connection restored"})
317 }317 }
318 },318 },
319 Spinner: presentation,319 Spinner: presentation,
M internal/domain/notice.go +12 -0
@@ -44,6 +44,7 @@ const (
4444 NoticeUser
4545 NoticeNavigationInventory
4646 NoticeRemoteObservation
47+ NoticeSessionKill
4748
4849 // noticeCodeLimit is an append-only declaration sentinel, not a persisted
4950 // valid notice code. Keep it last when adding a NoticeCode.
@@ -74,6 +75,7 @@ var noticeSlugs = map[NoticeCode]string{
7475 NoticeUser: "user",
7576 NoticeNavigationInventory: "navigation-inventory",
7677 NoticeRemoteObservation: "remote-observation",
78+ NoticeSessionKill: "session-kill",
7779 }
7880
7981 func (c NoticeCode) String() string {
@@ -120,4 +122,14 @@ type Notification struct {
120122 Time time.Time
121123 Count int // >1 when coalesced
122124 SessionID SessionID // "" = daemon-global
125+ // Scope narrows the subject below the session, such as one remote host.
126+ // Notices with the same code, session, and scope replace each other.
127+ Scope string `json:",omitempty"`
128+}
129+
130+// Subject is the stable identity of what a notice is about. Client toasts
131+// use it so a newer notice for the same subject replaces the visible one;
132+// the daemon's notice coalescing keeps its own rule (sameToastNotice).
133+func (n Notification) Subject() string {
134+ return n.Code.String() + "/" + string(n.SessionID) + "/" + n.Scope
123135 }
@@ -44,6 +44,7 @@ const (
44 NoticeUser44 NoticeUser
45 NoticeNavigationInventory45 NoticeNavigationInventory
46 NoticeRemoteObservation46 NoticeRemoteObservation
47+ NoticeSessionKill
47 48
48 // noticeCodeLimit is an append-only declaration sentinel, not a persisted49 // noticeCodeLimit is an append-only declaration sentinel, not a persisted
49 // valid notice code. Keep it last when adding a NoticeCode.50 // valid notice code. Keep it last when adding a NoticeCode.
@@ -74,6 +75,7 @@ var noticeSlugs = map[NoticeCode]string{
74 NoticeUser: "user",75 NoticeUser: "user",
75 NoticeNavigationInventory: "navigation-inventory",76 NoticeNavigationInventory: "navigation-inventory",
76 NoticeRemoteObservation: "remote-observation",77 NoticeRemoteObservation: "remote-observation",
78+ NoticeSessionKill: "session-kill",
77 }79 }
78 80
79 func (c NoticeCode) String() string {81 func (c NoticeCode) String() string {
@@ -120,4 +122,14 @@ type Notification struct {
120 Time time.Time122 Time time.Time
121 Count int // >1 when coalesced123 Count int // >1 when coalesced
122 SessionID SessionID // "" = daemon-global124 SessionID SessionID // "" = daemon-global
125+ // Scope narrows the subject below the session, such as one remote host.
126+ // Notices with the same code, session, and scope replace each other.
127+ Scope string `json:",omitempty"`
128+}
129+
130+// Subject is the stable identity of what a notice is about. Client toasts
131+// use it so a newer notice for the same subject replaces the visible one;
132+// the daemon's notice coalescing keeps its own rule (sameToastNotice).
133+func (n Notification) Subject() string {
134+ return n.Code.String() + "/" + string(n.SessionID) + "/" + n.Scope
123 }135 }
A internal/domain/remote_notice.go +70 -0
@@ -0,0 +1,70 @@
1+package domain
2+
3+// Failing reports an availability that cannot serve sessions right now.
4+func (a RemoteAvailability) Failing() bool {
5+ switch a {
6+ case RemoteAvailabilityUnreachable, RemoteAvailabilityAuthFailed,
7+ RemoteAvailabilityInvalidResponse, RemoteAvailabilityNoDaemon,
8+ RemoteAvailabilityIncompatible:
9+ return true
10+ default:
11+ return false
12+ }
13+}
14+
15+// RemoteHealth is the notice-relevant state of one observed remote host.
16+type RemoteHealth struct {
17+ Availability RemoteAvailability
18+ VersionMismatch bool
19+ // Episode increments on each new failure episode.
20+ Episode uint64
21+}
22+
23+// Failing reports a host that cannot serve sessions right now.
24+func (h RemoteHealth) Failing() bool {
25+ return h.VersionMismatch || h.Availability.Failing()
26+}
27+
28+func (h RemoteHealth) healthy() bool {
29+ return h.Availability == RemoteAvailabilityReachable && !h.VersionMismatch
30+}
31+
32+// settled reports a state worth remembering: in-between states (unknown,
33+// checking) keep the last settled one so a later recovery is still seen.
34+func (h RemoteHealth) settled() bool {
35+ return h.Failing() || h.Availability == RemoteAvailabilityReachable
36+}
37+
38+// RemoteHealthEvent is what one host transition is worth telling the user.
39+type RemoteHealthEvent uint8
40+
41+const (
42+ RemoteHealthSilent RemoteHealthEvent = iota
43+ // RemoteHealthFailed is a new failure episode or a new failure class.
44+ RemoteHealthFailed
45+ // RemoteHealthRecovered is a host serving again after a real failure.
46+ RemoteHealthRecovered
47+)
48+
49+// RemoteHealthTransition judges one host observation against the last
50+// remembered state (seen is false when there is none). It returns the state
51+// to remember and the event worth one notice. Ordinary progress (checking,
52+// first sighting of a healthy host) is silent. A host whose daemon just
53+// started after "no daemon" was never disconnected, so it is silent too.
54+func RemoteHealthTransition(prev RemoteHealth, seen bool, cur RemoteHealth) (RemoteHealth, RemoteHealthEvent) {
55+ next := cur
56+ if seen && !cur.settled() {
57+ next = prev
58+ }
59+ switch {
60+ case cur.Failing():
61+ if seen && prev.Failing() && prev.Episode == cur.Episode && prev.Availability == cur.Availability {
62+ return next, RemoteHealthSilent
63+ }
64+ return next, RemoteHealthFailed
65+ case seen && prev.Failing() && prev.Availability != RemoteAvailabilityNoDaemon && cur.healthy():
66+ return next, RemoteHealthRecovered
67+ default:
68+ return next, RemoteHealthSilent
69+ }
70+}
@@ -0,0 +1,70 @@
1+package domain
2+
3+// Failing reports an availability that cannot serve sessions right now.
4+func (a RemoteAvailability) Failing() bool {
5+ switch a {
6+ case RemoteAvailabilityUnreachable, RemoteAvailabilityAuthFailed,
7+ RemoteAvailabilityInvalidResponse, RemoteAvailabilityNoDaemon,
8+ RemoteAvailabilityIncompatible:
9+ return true
10+ default:
11+ return false
12+ }
13+}
14+
15+// RemoteHealth is the notice-relevant state of one observed remote host.
16+type RemoteHealth struct {
17+ Availability RemoteAvailability
18+ VersionMismatch bool
19+ // Episode increments on each new failure episode.
20+ Episode uint64
21+}
22+
23+// Failing reports a host that cannot serve sessions right now.
24+func (h RemoteHealth) Failing() bool {
25+ return h.VersionMismatch || h.Availability.Failing()
26+}
27+
28+func (h RemoteHealth) healthy() bool {
29+ return h.Availability == RemoteAvailabilityReachable && !h.VersionMismatch
30+}
31+
32+// settled reports a state worth remembering: in-between states (unknown,
33+// checking) keep the last settled one so a later recovery is still seen.
34+func (h RemoteHealth) settled() bool {
35+ return h.Failing() || h.Availability == RemoteAvailabilityReachable
36+}
37+
38+// RemoteHealthEvent is what one host transition is worth telling the user.
39+type RemoteHealthEvent uint8
40+
41+const (
42+ RemoteHealthSilent RemoteHealthEvent = iota
43+ // RemoteHealthFailed is a new failure episode or a new failure class.
44+ RemoteHealthFailed
45+ // RemoteHealthRecovered is a host serving again after a real failure.
46+ RemoteHealthRecovered
47+)
48+
49+// RemoteHealthTransition judges one host observation against the last
50+// remembered state (seen is false when there is none). It returns the state
51+// to remember and the event worth one notice. Ordinary progress (checking,
52+// first sighting of a healthy host) is silent. A host whose daemon just
53+// started after "no daemon" was never disconnected, so it is silent too.
54+func RemoteHealthTransition(prev RemoteHealth, seen bool, cur RemoteHealth) (RemoteHealth, RemoteHealthEvent) {
55+ next := cur
56+ if seen && !cur.settled() {
57+ next = prev
58+ }
59+ switch {
60+ case cur.Failing():
61+ if seen && prev.Failing() && prev.Episode == cur.Episode && prev.Availability == cur.Availability {
62+ return next, RemoteHealthSilent
63+ }
64+ return next, RemoteHealthFailed
65+ case seen && prev.Failing() && prev.Availability != RemoteAvailabilityNoDaemon && cur.healthy():
66+ return next, RemoteHealthRecovered
67+ default:
68+ return next, RemoteHealthSilent
69+ }
70+}
A internal/domain/remote_notice_test.go +84 -0
@@ -0,0 +1,84 @@
1+package domain
2+
3+import "testing"
4+
5+func TestRemoteHealthTransition(t *testing.T) {
6+ healthy := RemoteHealth{Availability: RemoteAvailabilityReachable}
7+ unknown := RemoteHealth{Availability: RemoteAvailabilityUnknown}
8+ noDaemon := RemoteHealth{Availability: RemoteAvailabilityNoDaemon, Episode: 1}
9+ down := func(episode uint64) RemoteHealth {
10+ return RemoteHealth{Availability: RemoteAvailabilityUnreachable, Episode: episode}
11+ }
12+ tests := []struct {
13+ name string
14+ prev RemoteHealth
15+ seen bool
16+ cur RemoteHealth
17+ want RemoteHealthEvent
18+ wantNext RemoteHealth
19+ }{
20+ {name: "first healthy sighting is silent", cur: healthy, want: RemoteHealthSilent, wantNext: healthy},
21+ {name: "first unknown sighting is silent", cur: unknown, want: RemoteHealthSilent, wantNext: unknown},
22+ {name: "healthy to healthy is silent", prev: healthy, seen: true, cur: healthy, want: RemoteHealthSilent, wantNext: healthy},
23+ {name: "first failing sighting fails", cur: down(1), want: RemoteHealthFailed, wantNext: down(1)},
24+ {name: "healthy to failing fails", prev: healthy, seen: true, cur: down(1), want: RemoteHealthFailed, wantNext: down(1)},
25+ {name: "same episode is silent", prev: down(1), seen: true, cur: down(1), want: RemoteHealthSilent, wantNext: down(1)},
26+ {name: "new episode fails", prev: down(1), seen: true, cur: down(2), want: RemoteHealthFailed, wantNext: down(2)},
27+ {name: "new failure class in same episode fails", prev: down(1), seen: true, cur: noDaemon, want: RemoteHealthFailed, wantNext: noDaemon},
28+ {name: "no daemon to unreachable fails", prev: noDaemon, seen: true, cur: down(2), want: RemoteHealthFailed, wantNext: down(2)},
29+ {name: "recovery", prev: down(1), seen: true, cur: healthy, want: RemoteHealthRecovered, wantNext: healthy},
30+ {name: "unknown keeps the failing state", prev: down(1), seen: true, cur: unknown, want: RemoteHealthSilent, wantNext: down(1)},
31+ {name: "daemon started after no daemon is silent", prev: noDaemon, seen: true, cur: healthy, want: RemoteHealthSilent, wantNext: healthy},
32+ {name: "version fixed recovers", prev: RemoteHealth{Availability: RemoteAvailabilityReachable, VersionMismatch: true}, seen: true, cur: healthy, want: RemoteHealthRecovered, wantNext: healthy},
33+ {name: "version mismatch fails", cur: RemoteHealth{Availability: RemoteAvailabilityReachable, VersionMismatch: true}, want: RemoteHealthFailed, wantNext: RemoteHealth{Availability: RemoteAvailabilityReachable, VersionMismatch: true}},
34+ {name: "incompatible fails", cur: RemoteHealth{Availability: RemoteAvailabilityIncompatible}, want: RemoteHealthFailed, wantNext: RemoteHealth{Availability: RemoteAvailabilityIncompatible}},
35+ }
36+ for _, tt := range tests {
37+ t.Run(tt.name, func(t *testing.T) {
38+ next, event := RemoteHealthTransition(tt.prev, tt.seen, tt.cur)
39+ if event != tt.want || next != tt.wantNext {
40+ t.Fatalf("got (%+v, %d), want (%+v, %d)", next, event, tt.wantNext, tt.want)
41+ }
42+ })
43+ }
44+}
45+
46+func TestRemoteHealthRecoveryAcrossUnknown(t *testing.T) {
47+ steps := []RemoteHealth{
48+ {Availability: RemoteAvailabilityUnreachable, Episode: 1},
49+ {Availability: RemoteAvailabilityUnknown, Episode: 1},
50+ {Availability: RemoteAvailabilityReachable, Episode: 1},
51+ }
52+ want := []RemoteHealthEvent{RemoteHealthFailed, RemoteHealthSilent, RemoteHealthRecovered}
53+ var prev RemoteHealth
54+ seen := false
55+ for i, cur := range steps {
56+ var event RemoteHealthEvent
57+ prev, event = RemoteHealthTransition(prev, seen, cur)
58+ seen = true
59+ if event != want[i] {
60+ t.Fatalf("step %d: event %d, want %d", i, event, want[i])
61+ }
62+ }
63+}
64+
65+func TestNotificationSubject(t *testing.T) {
66+ base := Notification{Code: NoticeRemoteObservation, SessionID: "s", Scope: "h", Message: "down"}
67+ tests := []struct {
68+ name string
69+ other Notification
70+ same bool
71+ }{
72+ {name: "same subject, other message", other: Notification{Code: NoticeRemoteObservation, SessionID: "s", Scope: "h", Message: "reconnected"}, same: true},
73+ {name: "other scope", other: Notification{Code: NoticeRemoteObservation, SessionID: "s", Scope: "other"}},
74+ {name: "other session", other: Notification{Code: NoticeRemoteObservation, SessionID: "t", Scope: "h"}},
75+ {name: "other code", other: Notification{Code: NoticeSessionKill, SessionID: "s", Scope: "h"}},
76+ }
77+ for _, tt := range tests {
78+ t.Run(tt.name, func(t *testing.T) {
79+ if got := base.Subject() == tt.other.Subject(); got != tt.same {
80+ t.Fatalf("same subject = %v, want %v", got, tt.same)
81+ }
82+ })
83+ }
84+}
@@ -0,0 +1,84 @@
1+package domain
2+
3+import "testing"
4+
5+func TestRemoteHealthTransition(t *testing.T) {
6+ healthy := RemoteHealth{Availability: RemoteAvailabilityReachable}
7+ unknown := RemoteHealth{Availability: RemoteAvailabilityUnknown}
8+ noDaemon := RemoteHealth{Availability: RemoteAvailabilityNoDaemon, Episode: 1}
9+ down := func(episode uint64) RemoteHealth {
10+ return RemoteHealth{Availability: RemoteAvailabilityUnreachable, Episode: episode}
11+ }
12+ tests := []struct {
13+ name string
14+ prev RemoteHealth
15+ seen bool
16+ cur RemoteHealth
17+ want RemoteHealthEvent
18+ wantNext RemoteHealth
19+ }{
20+ {name: "first healthy sighting is silent", cur: healthy, want: RemoteHealthSilent, wantNext: healthy},
21+ {name: "first unknown sighting is silent", cur: unknown, want: RemoteHealthSilent, wantNext: unknown},
22+ {name: "healthy to healthy is silent", prev: healthy, seen: true, cur: healthy, want: RemoteHealthSilent, wantNext: healthy},
23+ {name: "first failing sighting fails", cur: down(1), want: RemoteHealthFailed, wantNext: down(1)},
24+ {name: "healthy to failing fails", prev: healthy, seen: true, cur: down(1), want: RemoteHealthFailed, wantNext: down(1)},
25+ {name: "same episode is silent", prev: down(1), seen: true, cur: down(1), want: RemoteHealthSilent, wantNext: down(1)},
26+ {name: "new episode fails", prev: down(1), seen: true, cur: down(2), want: RemoteHealthFailed, wantNext: down(2)},
27+ {name: "new failure class in same episode fails", prev: down(1), seen: true, cur: noDaemon, want: RemoteHealthFailed, wantNext: noDaemon},
28+ {name: "no daemon to unreachable fails", prev: noDaemon, seen: true, cur: down(2), want: RemoteHealthFailed, wantNext: down(2)},
29+ {name: "recovery", prev: down(1), seen: true, cur: healthy, want: RemoteHealthRecovered, wantNext: healthy},
30+ {name: "unknown keeps the failing state", prev: down(1), seen: true, cur: unknown, want: RemoteHealthSilent, wantNext: down(1)},
31+ {name: "daemon started after no daemon is silent", prev: noDaemon, seen: true, cur: healthy, want: RemoteHealthSilent, wantNext: healthy},
32+ {name: "version fixed recovers", prev: RemoteHealth{Availability: RemoteAvailabilityReachable, VersionMismatch: true}, seen: true, cur: healthy, want: RemoteHealthRecovered, wantNext: healthy},
33+ {name: "version mismatch fails", cur: RemoteHealth{Availability: RemoteAvailabilityReachable, VersionMismatch: true}, want: RemoteHealthFailed, wantNext: RemoteHealth{Availability: RemoteAvailabilityReachable, VersionMismatch: true}},
34+ {name: "incompatible fails", cur: RemoteHealth{Availability: RemoteAvailabilityIncompatible}, want: RemoteHealthFailed, wantNext: RemoteHealth{Availability: RemoteAvailabilityIncompatible}},
35+ }
36+ for _, tt := range tests {
37+ t.Run(tt.name, func(t *testing.T) {
38+ next, event := RemoteHealthTransition(tt.prev, tt.seen, tt.cur)
39+ if event != tt.want || next != tt.wantNext {
40+ t.Fatalf("got (%+v, %d), want (%+v, %d)", next, event, tt.wantNext, tt.want)
41+ }
42+ })
43+ }
44+}
45+
46+func TestRemoteHealthRecoveryAcrossUnknown(t *testing.T) {
47+ steps := []RemoteHealth{
48+ {Availability: RemoteAvailabilityUnreachable, Episode: 1},
49+ {Availability: RemoteAvailabilityUnknown, Episode: 1},
50+ {Availability: RemoteAvailabilityReachable, Episode: 1},
51+ }
52+ want := []RemoteHealthEvent{RemoteHealthFailed, RemoteHealthSilent, RemoteHealthRecovered}
53+ var prev RemoteHealth
54+ seen := false
55+ for i, cur := range steps {
56+ var event RemoteHealthEvent
57+ prev, event = RemoteHealthTransition(prev, seen, cur)
58+ seen = true
59+ if event != want[i] {
60+ t.Fatalf("step %d: event %d, want %d", i, event, want[i])
61+ }
62+ }
63+}
64+
65+func TestNotificationSubject(t *testing.T) {
66+ base := Notification{Code: NoticeRemoteObservation, SessionID: "s", Scope: "h", Message: "down"}
67+ tests := []struct {
68+ name string
69+ other Notification
70+ same bool
71+ }{
72+ {name: "same subject, other message", other: Notification{Code: NoticeRemoteObservation, SessionID: "s", Scope: "h", Message: "reconnected"}, same: true},
73+ {name: "other scope", other: Notification{Code: NoticeRemoteObservation, SessionID: "s", Scope: "other"}},
74+ {name: "other session", other: Notification{Code: NoticeRemoteObservation, SessionID: "t", Scope: "h"}},
75+ {name: "other code", other: Notification{Code: NoticeSessionKill, SessionID: "s", Scope: "h"}},
76+ }
77+ for _, tt := range tests {
78+ t.Run(tt.name, func(t *testing.T) {
79+ if got := base.Subject() == tt.other.Subject(); got != tt.same {
80+ t.Fatalf("same subject = %v, want %v", got, tt.same)
81+ }
82+ })
83+ }
84+}
R internal/usecase/client/reconnect_toast.go → internal/usecase/client/client_toast.go +40 -6 renamed, 52% similar
@@ -7,6 +7,7 @@ import (
77
88 renderer "github.com/bnema/vev-vt"
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/usecase/picker"
1011 "github.com/bnema/vev/internal/usecase/ui"
1112 )
1213
@@ -14,16 +15,32 @@ import (
1415 // The attach main loop owns both this write and the later daemon-frame
1516 // reconciliation; input pumps must only publish a request for it.
1617 // The anchor places it: transitions stay centered, notices sit top-right so
17-// they never cover the picker list.
18-func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor) (domain.Rect, error) {
18+// they never cover the picker list. The border is colored by severity.
19+func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor, severity domain.NoticeSeverity) (domain.Rect, error) {
1920 bounds := ui.ToastBounds(size, ui.Toast{Message: message, Anchor: anchor})
2021 if bounds.Width <= 0 || bounds.Height <= 0 {
2122 return domain.Rect{}, nil
2223 }
23- return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))
24+ return bounds, writeClientToast(out, bounds, clientToastLines(bounds, message, toastBorderSGR(severity)))
2425 }
2526
26-func reconnectToastLinesFor(bounds domain.Rect, message string) []string {
27+// toastBorderSGR is the border color per severity, in the same fixed
28+// xterm-256 colors as the picker's problem dots. Info keeps the terminal's
29+// default color.
30+func toastBorderSGR(severity domain.NoticeSeverity) string {
31+ switch severity {
32+ case domain.NoticeError:
33+ return fmt.Sprintf("\x1b[38;5;%dm", picker.ColorProblemError)
34+ case domain.NoticeWarn:
35+ return fmt.Sprintf("\x1b[38;5;%dm", picker.ColorProblemWarn)
36+ default:
37+ return ""
38+ }
39+}
40+
41+// clientToastLines renders the toast box as one string per row, exactly
42+// bounds.Width columns wide.
43+func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
2744 if bounds.Width <= 0 || bounds.Height <= 0 {
2845 return nil
2946 }
@@ -36,20 +53,37 @@ func reconnectToastLinesFor(bounds domain.Rect, message string) []string {
3653 for y := range bounds.Height {
3754 var b strings.Builder
3855 b.Grow(bounds.Width)
56+ colored := false
3957 for x := range bounds.Width {
58+ border := y == 0 || y == bounds.Height-1 || x == 0 || x == bounds.Width-1
59+ if borderSGR != "" && border != colored {
60+ if border {
61+ b.WriteString(borderSGR)
62+ } else {
63+ b.WriteString("\x1b[39m")
64+ }
65+ colored = border
66+ }
4067 cell := frame.At(x, y)
41- if cell.Continuation || cell.Rune == 0 {
68+ if cell.Continuation {
69+ // The wide rune before it already covers this column.
70+ continue
71+ }
72+ if cell.Rune == 0 {
4273 b.WriteRune(' ')
4374 continue
4475 }
4576 b.WriteRune(cell.Rune)
4677 }
78+ if colored {
79+ b.WriteString("\x1b[39m")
80+ }
4781 lines[y] = b.String()
4882 }
4983 return lines
5084 }
5185
52-func writeReconnectToast(out io.Writer, bounds domain.Rect, lines []string) error {
86+func writeClientToast(out io.Writer, bounds domain.Rect, lines []string) error {
5387 if _, err := io.WriteString(out, "\x1b[s"); err != nil {
5488 return err
5589 }
@@ -7,6 +7,7 @@ import (
7 7
8 renderer "github.com/bnema/vev-vt"8 renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/usecase/picker"
10 "github.com/bnema/vev/internal/usecase/ui"11 "github.com/bnema/vev/internal/usecase/ui"
11 )12 )
12 13
@@ -14,16 +15,32 @@ import (
14 // The attach main loop owns both this write and the later daemon-frame15 // The attach main loop owns both this write and the later daemon-frame
15 // reconciliation; input pumps must only publish a request for it.16 // reconciliation; input pumps must only publish a request for it.
16 // The anchor places it: transitions stay centered, notices sit top-right so17 // The anchor places it: transitions stay centered, notices sit top-right so
17-// they never cover the picker list.18+// they never cover the picker list. The border is colored by severity.
18-func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor) (domain.Rect, error) {19+func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor, severity domain.NoticeSeverity) (domain.Rect, error) {
19 bounds := ui.ToastBounds(size, ui.Toast{Message: message, Anchor: anchor})20 bounds := ui.ToastBounds(size, ui.Toast{Message: message, Anchor: anchor})
20 if bounds.Width <= 0 || bounds.Height <= 0 {21 if bounds.Width <= 0 || bounds.Height <= 0 {
21 return domain.Rect{}, nil22 return domain.Rect{}, nil
22 }23 }
23- return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message))24+ return bounds, writeClientToast(out, bounds, clientToastLines(bounds, message, toastBorderSGR(severity)))
24 }25 }
25 26
26-func reconnectToastLinesFor(bounds domain.Rect, message string) []string {27+// toastBorderSGR is the border color per severity, in the same fixed
28+// xterm-256 colors as the picker's problem dots. Info keeps the terminal's
29+// default color.
30+func toastBorderSGR(severity domain.NoticeSeverity) string {
31+ switch severity {
32+ case domain.NoticeError:
33+ return fmt.Sprintf("\x1b[38;5;%dm", picker.ColorProblemError)
34+ case domain.NoticeWarn:
35+ return fmt.Sprintf("\x1b[38;5;%dm", picker.ColorProblemWarn)
36+ default:
37+ return ""
38+ }
39+}
40+
41+// clientToastLines renders the toast box as one string per row, exactly
42+// bounds.Width columns wide.
43+func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
27 if bounds.Width <= 0 || bounds.Height <= 0 {44 if bounds.Width <= 0 || bounds.Height <= 0 {
28 return nil45 return nil
29 }46 }
@@ -36,20 +53,37 @@ func reconnectToastLinesFor(bounds domain.Rect, message string) []string {
36 for y := range bounds.Height {53 for y := range bounds.Height {
37 var b strings.Builder54 var b strings.Builder
38 b.Grow(bounds.Width)55 b.Grow(bounds.Width)
56+ colored := false
39 for x := range bounds.Width {57 for x := range bounds.Width {
58+ border := y == 0 || y == bounds.Height-1 || x == 0 || x == bounds.Width-1
59+ if borderSGR != "" && border != colored {
60+ if border {
61+ b.WriteString(borderSGR)
62+ } else {
63+ b.WriteString("\x1b[39m")
64+ }
65+ colored = border
66+ }
40 cell := frame.At(x, y)67 cell := frame.At(x, y)
41- if cell.Continuation || cell.Rune == 0 {68+ if cell.Continuation {
69+ // The wide rune before it already covers this column.
70+ continue
71+ }
72+ if cell.Rune == 0 {
42 b.WriteRune(' ')73 b.WriteRune(' ')
43 continue74 continue
44 }75 }
45 b.WriteRune(cell.Rune)76 b.WriteRune(cell.Rune)
46 }77 }
78+ if colored {
79+ b.WriteString("\x1b[39m")
80+ }
47 lines[y] = b.String()81 lines[y] = b.String()
48 }82 }
49 return lines83 return lines
50 }84 }
51 85
52-func writeReconnectToast(out io.Writer, bounds domain.Rect, lines []string) error {86+func writeClientToast(out io.Writer, bounds domain.Rect, lines []string) error {
53 if _, err := io.WriteString(out, "\x1b[s"); err != nil {87 if _, err := io.WriteString(out, "\x1b[s"); err != nil {
54 return err88 return err
55 }89 }
A internal/usecase/client/client_toast_test.go +91 -0
@@ -0,0 +1,91 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "regexp"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ renderer "github.com/bnema/vev-vt"
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/usecase/ui"
13+)
14+
15+func TestDrawClientToastPlacement(t *testing.T) {
16+ size := domain.Size{Cols: 100, Rows: 30}
17+ tests := []struct {
18+ name string
19+ anchor domain.Anchor
20+ check func(t *testing.T, bounds domain.Rect)
21+ }{
22+ {name: "notice sits top-right", anchor: domain.AnchorTopRight, check: func(t *testing.T, bounds domain.Rect) {
23+ require.Less(t, bounds.Y, 3)
24+ require.Greater(t, bounds.X+bounds.Width, size.Cols-3)
25+ }},
26+ {name: "transition stays centered", anchor: domain.AnchorCenter, check: func(t *testing.T, bounds domain.Rect) {
27+ require.Equal(t, (size.Rows-bounds.Height)/2, bounds.Y)
28+ require.Equal(t, (size.Cols-bounds.Width)/2, bounds.X)
29+ }},
30+ }
31+ for _, tt := range tests {
32+ t.Run(tt.name, func(t *testing.T) {
33+ var out bytes.Buffer
34+ bounds, err := drawClientToast(&out, size, "SSH authentication failed", tt.anchor, domain.NoticeInfo)
35+ require.NoError(t, err)
36+ require.NotZero(t, out.Len())
37+ tt.check(t, bounds)
38+ })
39+ }
40+}
41+
42+func TestClientToastLinesKeepExactWidth(t *testing.T) {
43+ sgr := regexp.MustCompile("\x1b\\[[0-9;]*m")
44+ tests := []struct {
45+ name string
46+ message string
47+ border string
48+ }{
49+ {name: "ascii", message: "host down"},
50+ {name: "wide rune", message: "hôte 東京 down"},
51+ {name: "colored border", message: "hôte 東京 down", border: toastBorderSGR(domain.NoticeError)},
52+ }
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ bounds := ui.ToastBounds(domain.Size{Cols: 100, Rows: 30}, ui.Toast{Message: tt.message, Anchor: domain.AnchorTopRight})
56+ for i, line := range clientToastLines(bounds, tt.message, tt.border) {
57+ width := 0
58+ for _, r := range sgr.ReplaceAllString(line, "") {
59+ width += renderer.RuneWidth(r)
60+ }
61+ require.Equal(t, bounds.Width, width, "row %d width", i)
62+ }
63+ })
64+ }
65+}
66+
67+func TestDrawClientToastSeverityBorder(t *testing.T) {
68+ size := domain.Size{Cols: 100, Rows: 30}
69+ tests := []struct {
70+ name string
71+ severity domain.NoticeSeverity
72+ want string
73+ }{
74+ {name: "error border is red", severity: domain.NoticeError, want: "\x1b[38;5;167m"},
75+ {name: "warn border is yellow", severity: domain.NoticeWarn, want: "\x1b[38;5;179m"},
76+ {name: "info keeps the default color", severity: domain.NoticeInfo},
77+ }
78+ for _, tt := range tests {
79+ t.Run(tt.name, func(t *testing.T) {
80+ var out bytes.Buffer
81+ _, err := drawClientToast(&out, size, "host down", domain.AnchorTopRight, tt.severity)
82+ require.NoError(t, err)
83+ if tt.want == "" {
84+ require.NotContains(t, out.String(), "\x1b[38;5;")
85+ return
86+ }
87+ require.Contains(t, out.String(), tt.want)
88+ require.Contains(t, out.String(), "host down")
89+ })
90+ }
91+}
@@ -0,0 +1,91 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "regexp"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ renderer "github.com/bnema/vev-vt"
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/usecase/ui"
13+)
14+
15+func TestDrawClientToastPlacement(t *testing.T) {
16+ size := domain.Size{Cols: 100, Rows: 30}
17+ tests := []struct {
18+ name string
19+ anchor domain.Anchor
20+ check func(t *testing.T, bounds domain.Rect)
21+ }{
22+ {name: "notice sits top-right", anchor: domain.AnchorTopRight, check: func(t *testing.T, bounds domain.Rect) {
23+ require.Less(t, bounds.Y, 3)
24+ require.Greater(t, bounds.X+bounds.Width, size.Cols-3)
25+ }},
26+ {name: "transition stays centered", anchor: domain.AnchorCenter, check: func(t *testing.T, bounds domain.Rect) {
27+ require.Equal(t, (size.Rows-bounds.Height)/2, bounds.Y)
28+ require.Equal(t, (size.Cols-bounds.Width)/2, bounds.X)
29+ }},
30+ }
31+ for _, tt := range tests {
32+ t.Run(tt.name, func(t *testing.T) {
33+ var out bytes.Buffer
34+ bounds, err := drawClientToast(&out, size, "SSH authentication failed", tt.anchor, domain.NoticeInfo)
35+ require.NoError(t, err)
36+ require.NotZero(t, out.Len())
37+ tt.check(t, bounds)
38+ })
39+ }
40+}
41+
42+func TestClientToastLinesKeepExactWidth(t *testing.T) {
43+ sgr := regexp.MustCompile("\x1b\\[[0-9;]*m")
44+ tests := []struct {
45+ name string
46+ message string
47+ border string
48+ }{
49+ {name: "ascii", message: "host down"},
50+ {name: "wide rune", message: "hôte 東京 down"},
51+ {name: "colored border", message: "hôte 東京 down", border: toastBorderSGR(domain.NoticeError)},
52+ }
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ bounds := ui.ToastBounds(domain.Size{Cols: 100, Rows: 30}, ui.Toast{Message: tt.message, Anchor: domain.AnchorTopRight})
56+ for i, line := range clientToastLines(bounds, tt.message, tt.border) {
57+ width := 0
58+ for _, r := range sgr.ReplaceAllString(line, "") {
59+ width += renderer.RuneWidth(r)
60+ }
61+ require.Equal(t, bounds.Width, width, "row %d width", i)
62+ }
63+ })
64+ }
65+}
66+
67+func TestDrawClientToastSeverityBorder(t *testing.T) {
68+ size := domain.Size{Cols: 100, Rows: 30}
69+ tests := []struct {
70+ name string
71+ severity domain.NoticeSeverity
72+ want string
73+ }{
74+ {name: "error border is red", severity: domain.NoticeError, want: "\x1b[38;5;167m"},
75+ {name: "warn border is yellow", severity: domain.NoticeWarn, want: "\x1b[38;5;179m"},
76+ {name: "info keeps the default color", severity: domain.NoticeInfo},
77+ }
78+ for _, tt := range tests {
79+ t.Run(tt.name, func(t *testing.T) {
80+ var out bytes.Buffer
81+ _, err := drawClientToast(&out, size, "host down", domain.AnchorTopRight, tt.severity)
82+ require.NoError(t, err)
83+ if tt.want == "" {
84+ require.NotContains(t, out.String(), "\x1b[38;5;")
85+ return
86+ }
87+ require.Contains(t, out.String(), tt.want)
88+ require.Contains(t, out.String(), "host down")
89+ })
90+ }
91+}
M internal/usecase/client/picker_catalogue.go +8 -12
@@ -869,7 +869,8 @@ func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bo
869869 if observation.ProtocolVersion == 0 || !fresh {
870870 return protocol.PickerLineStatusStale
871871 }
872- return protocol.PickerLineStatusUp
872+ // A healthy host carries no dot.
873+ return protocol.PickerLineStatusNone
873874 default:
874875 // Availability Unknown (unobserved) and any out-of-range value are
875876 // never treated as compatible.
@@ -919,22 +920,17 @@ func pickerHostDetail(observation ports.BrokerDaemonObservation) string {
919920 if !observation.InventoryKnown {
920921 return "…"
921922 }
922- // A host row is only published when it has no sessions: the status dot
923- // already says why, so no count is shown.
923+ // The problem dot and its toast already say why no session is listed.
924924 return ""
925925 }
926926
927+// pickerSessionStatus marks only a broken session. A live session is the
928+// normal case and a stopped one is already muted, so neither carries a dot.
927929 func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.PickerLineStatus {
928- switch session.State {
929- case catalogue.RemoteCatalogSessionUp:
930- return protocol.PickerLineStatusUp
931- case catalogue.RemoteCatalogSessionDown:
932- return protocol.PickerLineStatusStopped
933- case catalogue.RemoteCatalogSessionBroken:
930+ if session.State == catalogue.RemoteCatalogSessionBroken {
934931 return protocol.PickerLineStatusError
935- default:
936- return protocol.PickerLineStatusNone
937932 }
933+ return protocol.PickerLineStatusNone
938934 }
939935
940936 func pickerSessionDetail(session catalogue.RemoteCatalogSession) string {
@@ -943,7 +939,7 @@ func pickerSessionDetail(session catalogue.RemoteCatalogSession) string {
943939 count = strconv.Itoa(n)
944940 }
945941 if session.Attached {
946- // "*" marks the attached session; ● is reserved for the status dot.
942+ // "*" marks the attached session; ● is reserved for the problem dot.
947943 return strings.TrimSpace("* " + count)
948944 }
949945 return count
@@ -869,7 +869,8 @@ func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bo
869 if observation.ProtocolVersion == 0 || !fresh {869 if observation.ProtocolVersion == 0 || !fresh {
870 return protocol.PickerLineStatusStale870 return protocol.PickerLineStatusStale
871 }871 }
872- return protocol.PickerLineStatusUp872+ // A healthy host carries no dot.
873+ return protocol.PickerLineStatusNone
873 default:874 default:
874 // Availability Unknown (unobserved) and any out-of-range value are875 // Availability Unknown (unobserved) and any out-of-range value are
875 // never treated as compatible.876 // never treated as compatible.
@@ -919,22 +920,17 @@ func pickerHostDetail(observation ports.BrokerDaemonObservation) string {
919 if !observation.InventoryKnown {920 if !observation.InventoryKnown {
920 return "…"921 return "…"
921 }922 }
922- // A host row is only published when it has no sessions: the status dot923+ // The problem dot and its toast already say why no session is listed.
923- // already says why, so no count is shown.
924 return ""924 return ""
925 }925 }
926 926
927+// pickerSessionStatus marks only a broken session. A live session is the
928+// normal case and a stopped one is already muted, so neither carries a dot.
927 func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.PickerLineStatus {929 func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.PickerLineStatus {
928- switch session.State {930+ if session.State == catalogue.RemoteCatalogSessionBroken {
929- case catalogue.RemoteCatalogSessionUp:
930- return protocol.PickerLineStatusUp
931- case catalogue.RemoteCatalogSessionDown:
932- return protocol.PickerLineStatusStopped
933- case catalogue.RemoteCatalogSessionBroken:
934 return protocol.PickerLineStatusError931 return protocol.PickerLineStatusError
935- default:
936- return protocol.PickerLineStatusNone
937 }932 }
933+ return protocol.PickerLineStatusNone
938 }934 }
939 935
940 func pickerSessionDetail(session catalogue.RemoteCatalogSession) string {936 func pickerSessionDetail(session catalogue.RemoteCatalogSession) string {
@@ -943,7 +939,7 @@ func pickerSessionDetail(session catalogue.RemoteCatalogSession) string {
943 count = strconv.Itoa(n)939 count = strconv.Itoa(n)
944 }940 }
945 if session.Attached {941 if session.Attached {
946- // "*" marks the attached session; ● is reserved for the status dot.942+ // "*" marks the attached session; ● is reserved for the problem dot.
947 return strings.TrimSpace("* " + count)943 return strings.TrimSpace("* " + count)
948 }944 }
949 return count945 return count
M internal/usecase/client/picker_catalogue_projection.go +64 -44
@@ -67,9 +67,11 @@ func (c *pickerCatalogue) projectLocked() {
6767 refs := make(map[string]pickerSelectionRef)
6868 var localLive, localStopped []pickerSessionBlock
6969 localOrigin := ""
70+ localStatus := protocol.PickerLineStatusNone
7071 hasLocal := false
7172 type remoteGroup struct {
7273 origin string
74+ status protocol.PickerLineStatus
7375 rows []pickerRow
7476 }
7577 var remotes []remoteGroup
@@ -100,7 +102,8 @@ func (c *pickerCatalogue) projectLocked() {
100102 localStopped = append(localStopped, block)
101103 }
102104 }
103- if len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable {
105+ hasHostRow := len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable
106+ if hasHostRow {
104107 hostRef := pickerSelectionRef{
105108 kind: pickerSelectionCreateEphemeral, epoch: c.snapshot.Epoch,
106109 local: observation.Local, endpoint: observation.Endpoint, registration: observation.Registration,
@@ -124,14 +127,22 @@ func (c *pickerCatalogue) projectLocked() {
124127 }(),
125128 }, ref: hostRef, hasRef: observation.Availability == domain.RemoteAvailabilityNoDaemon})
126129 }
130+ // The section carries the host's problem dot unless a host row below
131+ // it already does. The local host never shows a transient stale dot:
132+ // no toast would explain it.
133+ sectionStatus := pickerObservationStatus(observation, fresh)
134+ if hasHostRow || observation.Local && sectionStatus == protocol.PickerLineStatusStale {
135+ sectionStatus = protocol.PickerLineStatusNone
136+ }
127137 if observation.Local {
128138 hasLocal = true
129139 localOrigin = origin
140+ localStatus = sectionStatus
130141 localHost = hostRows
131142 continue
132143 }
133144 if len(hostRows) != 0 {
134- remotes = append(remotes, remoteGroup{origin: origin, rows: hostRows})
145+ remotes = append(remotes, remoteGroup{origin: origin, status: sectionStatus, rows: hostRows})
135146 }
136147 }
137148 sortPickerBlocks(localLive)
@@ -170,11 +181,11 @@ func (c *pickerCatalogue) projectLocked() {
170181
171182 localRows = append(localRows, stoppedRows...)
172183 if hasLocal && len(localRows) != 0 {
173- grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: localOrigin, Dim: true}})
184+ grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: localOrigin, Status: localStatus, Dim: true}})
174185 grouped = append(grouped, localRows...)
175186 }
176187 for _, remote := range remotes {
177- grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: remote.origin, Dim: true}})
188+ grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: remote.origin, Status: remote.status, Dim: true}})
178189 grouped = append(grouped, remote.rows...)
179190 }
180191
@@ -447,14 +458,10 @@ func pickerResolveTab(ref pickerSelectionRef, session catalogue.RemoteCatalogSes
447458 }
448459
449460 // pickerObservationFailing reports a host the broker observed failing: its
450-// last probe could not reach it, authenticate, or read its catalogue.
461+// last probe could not reach it, authenticate, or read its catalogue. An
462+// incompatible host is reachable and handled apart.
451463 func pickerObservationFailing(observation ports.BrokerDaemonObservation) bool {
452- switch observation.Availability {
453- case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed, domain.RemoteAvailabilityInvalidResponse, domain.RemoteAvailabilityNoDaemon:
454- return true
455- default:
456- return false
457- }
464+ return observation.Availability.Failing() && observation.Availability != domain.RemoteAvailabilityIncompatible
458465 }
459466
460467 // pickerUnavailableNotice is main's remote-unavailable notice: the session
@@ -558,66 +565,79 @@ func (c *pickerCatalogue) ResolveKill(key string) (pickerKillTarget, error) {
558565 }, nil
559566 }
560567
561-// pickerHostFailure is one remote host failure episode worth one toast.
562-type pickerHostFailure struct {
563- key string
564- episode uint64
565- message string
568+// pickerHostHealth is one remote host's notice-relevant state.
569+type pickerHostHealth struct {
570+ key string
571+ observation ports.BrokerDaemonObservation
572+ health domain.RemoteHealth
566573 }
567574
568-// failures reports every remote host currently in a failure episode. The
569-// controller toasts each (host, episode) once, as main's
570-// notifyNewRemoteFailures did.
571-func (c *pickerCatalogue) failures() []pickerHostFailure {
575+// remoteHealth reports every remote host's state, in catalogue order.
576+// Transition policy lives in domain.RemoteHealthTransition.
577+func (c *pickerCatalogue) remoteHealth() []pickerHostHealth {
572578 if c == nil {
573579 return nil
574580 }
575581 c.mu.Lock()
576582 defer c.mu.Unlock()
577- failures := make([]pickerHostFailure, 0)
583+ hosts := make([]pickerHostHealth, 0, len(c.order))
578584 for _, key := range c.order {
579585 host, ok := c.hosts[key]
580586 if !ok || host.observation.Local {
581587 continue
582588 }
583589 observation := host.observation
584- if !pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityIncompatible && !pickerObservationVersionMismatch(observation) {
585- continue
586- }
587- failures = append(failures, pickerHostFailure{
588- key: key,
589- episode: observation.FailureEpisode,
590- message: pickerHostFailureMessage(observation),
591- })
590+ hosts = append(hosts, pickerHostHealth{key: key, observation: observation, health: domain.RemoteHealth{
591+ Availability: observation.Availability,
592+ VersionMismatch: pickerObservationVersionMismatch(observation),
593+ Episode: observation.FailureEpisode,
594+ }})
592595 }
593- return failures
596+ return hosts
597+}
598+
599+// pickerHostHealthNotice renders one host event as a toast scoped to the host,
600+// so a recovery replaces the visible failure.
601+func pickerHostHealthNotice(host pickerHostHealth, event domain.RemoteHealthEvent) domain.Notification {
602+ n := domain.Notification{Code: domain.NoticeRemoteObservation, Scope: host.key}
603+ origin := pickerOriginLabel(host.observation)
604+ if event == domain.RemoteHealthRecovered {
605+ n.Severity = domain.NoticeInfo
606+ n.Message = "Remote host reconnected: " + origin
607+ return n
608+ }
609+ n.Severity = domain.NoticeError
610+ if host.observation.Availability == domain.RemoteAvailabilityNoDaemon {
611+ n.Severity = domain.NoticeWarn
612+ }
613+ n.Message = "Remote check failed: " + origin + " — " + pickerHostFailureText(host.observation, host.health.VersionMismatch)
614+ return n
594615 }
595616
596-// pickerHostFailureMessage is main's remoteFailureNoticeMessage, named by the
597-// host's display origin rather than a raw endpoint.
598-func pickerHostFailureMessage(observation ports.BrokerDaemonObservation) string {
599- prefix := "Remote check failed: " + pickerOriginLabel(observation) + " — "
600- if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) {
601- return prefix + "remote vev version is incompatible"
617+func pickerHostFailureText(observation ports.BrokerDaemonObservation, versionMismatch bool) string {
618+ if observation.Availability == domain.RemoteAvailabilityIncompatible || versionMismatch {
619+ return "remote vev version is incompatible"
602620 }
603621 switch observation.LastFailure.Kind {
604622 case domain.RemoteFailureAuthentication:
605- return prefix + "SSH authentication failed; verify non-interactive SSH access"
623+ return "SSH authentication failed; verify non-interactive SSH access"
606624 case domain.RemoteFailureTrust:
607- return prefix + "SSH host verification failed; verify the host key policy"
625+ return "SSH host verification failed; verify the host key policy"
608626 case domain.RemoteFailureIncompatible:
609- return prefix + "remote vev version is incompatible"
627+ return "remote vev version is incompatible"
610628 case domain.RemoteFailureInvalidResponse:
611- return prefix + "remote catalog response is invalid"
629+ return "remote catalog response is invalid"
612630 case domain.RemoteFailureTimeout:
613- return prefix + "SSH timed out"
631+ return "SSH timed out"
614632 }
615633 switch observation.Availability {
616634 case domain.RemoteAvailabilityAuthFailed:
617- return prefix + "SSH authentication failed; verify non-interactive SSH access"
635+ return "SSH authentication failed; verify non-interactive SSH access"
618636 case domain.RemoteAvailabilityInvalidResponse:
619- return prefix + "remote catalog response is invalid"
637+ return "remote catalog response is invalid"
638+ case domain.RemoteAvailabilityNoDaemon:
639+ return "no vev daemon is running"
620640 default:
621- return prefix + "SSH connection failed; verify SSH access"
641+ return "SSH connection failed; verify SSH access"
622642 }
623643 }
@@ -67,9 +67,11 @@ func (c *pickerCatalogue) projectLocked() {
67 refs := make(map[string]pickerSelectionRef)67 refs := make(map[string]pickerSelectionRef)
68 var localLive, localStopped []pickerSessionBlock68 var localLive, localStopped []pickerSessionBlock
69 localOrigin := ""69 localOrigin := ""
70+ localStatus := protocol.PickerLineStatusNone
70 hasLocal := false71 hasLocal := false
71 type remoteGroup struct {72 type remoteGroup struct {
72 origin string73 origin string
74+ status protocol.PickerLineStatus
73 rows []pickerRow75 rows []pickerRow
74 }76 }
75 var remotes []remoteGroup77 var remotes []remoteGroup
@@ -100,7 +102,8 @@ func (c *pickerCatalogue) projectLocked() {
100 localStopped = append(localStopped, block)102 localStopped = append(localStopped, block)
101 }103 }
102 }104 }
103- if len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable {105+ hasHostRow := len(observation.Sessions) == 0 && observation.Availability != domain.RemoteAvailabilityReachable
106+ if hasHostRow {
104 hostRef := pickerSelectionRef{107 hostRef := pickerSelectionRef{
105 kind: pickerSelectionCreateEphemeral, epoch: c.snapshot.Epoch,108 kind: pickerSelectionCreateEphemeral, epoch: c.snapshot.Epoch,
106 local: observation.Local, endpoint: observation.Endpoint, registration: observation.Registration,109 local: observation.Local, endpoint: observation.Endpoint, registration: observation.Registration,
@@ -124,14 +127,22 @@ func (c *pickerCatalogue) projectLocked() {
124 }(),127 }(),
125 }, ref: hostRef, hasRef: observation.Availability == domain.RemoteAvailabilityNoDaemon})128 }, ref: hostRef, hasRef: observation.Availability == domain.RemoteAvailabilityNoDaemon})
126 }129 }
130+ // The section carries the host's problem dot unless a host row below
131+ // it already does. The local host never shows a transient stale dot:
132+ // no toast would explain it.
133+ sectionStatus := pickerObservationStatus(observation, fresh)
134+ if hasHostRow || observation.Local && sectionStatus == protocol.PickerLineStatusStale {
135+ sectionStatus = protocol.PickerLineStatusNone
136+ }
127 if observation.Local {137 if observation.Local {
128 hasLocal = true138 hasLocal = true
129 localOrigin = origin139 localOrigin = origin
140+ localStatus = sectionStatus
130 localHost = hostRows141 localHost = hostRows
131 continue142 continue
132 }143 }
133 if len(hostRows) != 0 {144 if len(hostRows) != 0 {
134- remotes = append(remotes, remoteGroup{origin: origin, rows: hostRows})145+ remotes = append(remotes, remoteGroup{origin: origin, status: sectionStatus, rows: hostRows})
135 }146 }
136 }147 }
137 sortPickerBlocks(localLive)148 sortPickerBlocks(localLive)
@@ -170,11 +181,11 @@ func (c *pickerCatalogue) projectLocked() {
170 181
171 localRows = append(localRows, stoppedRows...)182 localRows = append(localRows, stoppedRows...)
172 if hasLocal && len(localRows) != 0 {183 if hasLocal && len(localRows) != 0 {
173- grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: localOrigin, Dim: true}})184+ grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: localOrigin, Status: localStatus, Dim: true}})
174 grouped = append(grouped, localRows...)185 grouped = append(grouped, localRows...)
175 }186 }
176 for _, remote := range remotes {187 for _, remote := range remotes {
177- grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: remote.origin, Dim: true}})188+ grouped = append(grouped, pickerRow{line: protocol.PickerLine{Kind: protocol.PickerLineSection, Label: remote.origin, Status: remote.status, Dim: true}})
178 grouped = append(grouped, remote.rows...)189 grouped = append(grouped, remote.rows...)
179 }190 }
180 191
@@ -447,14 +458,10 @@ func pickerResolveTab(ref pickerSelectionRef, session catalogue.RemoteCatalogSes
447 }458 }
448 459
449 // pickerObservationFailing reports a host the broker observed failing: its460 // pickerObservationFailing reports a host the broker observed failing: its
450-// last probe could not reach it, authenticate, or read its catalogue.461+// last probe could not reach it, authenticate, or read its catalogue. An
462+// incompatible host is reachable and handled apart.
451 func pickerObservationFailing(observation ports.BrokerDaemonObservation) bool {463 func pickerObservationFailing(observation ports.BrokerDaemonObservation) bool {
452- switch observation.Availability {464+ return observation.Availability.Failing() && observation.Availability != domain.RemoteAvailabilityIncompatible
453- case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed, domain.RemoteAvailabilityInvalidResponse, domain.RemoteAvailabilityNoDaemon:
454- return true
455- default:
456- return false
457- }
458 }465 }
459 466
460 // pickerUnavailableNotice is main's remote-unavailable notice: the session467 // pickerUnavailableNotice is main's remote-unavailable notice: the session
@@ -558,66 +565,79 @@ func (c *pickerCatalogue) ResolveKill(key string) (pickerKillTarget, error) {
558 }, nil565 }, nil
559 }566 }
560 567
561-// pickerHostFailure is one remote host failure episode worth one toast.568+// pickerHostHealth is one remote host's notice-relevant state.
562-type pickerHostFailure struct {569+type pickerHostHealth struct {
563- key string570+ key string
564- episode uint64571+ observation ports.BrokerDaemonObservation
565- message string572+ health domain.RemoteHealth
566 }573 }
567 574
568-// failures reports every remote host currently in a failure episode. The575+// remoteHealth reports every remote host's state, in catalogue order.
569-// controller toasts each (host, episode) once, as main's576+// Transition policy lives in domain.RemoteHealthTransition.
570-// notifyNewRemoteFailures did.577+func (c *pickerCatalogue) remoteHealth() []pickerHostHealth {
571-func (c *pickerCatalogue) failures() []pickerHostFailure {
572 if c == nil {578 if c == nil {
573 return nil579 return nil
574 }580 }
575 c.mu.Lock()581 c.mu.Lock()
576 defer c.mu.Unlock()582 defer c.mu.Unlock()
577- failures := make([]pickerHostFailure, 0)583+ hosts := make([]pickerHostHealth, 0, len(c.order))
578 for _, key := range c.order {584 for _, key := range c.order {
579 host, ok := c.hosts[key]585 host, ok := c.hosts[key]
580 if !ok || host.observation.Local {586 if !ok || host.observation.Local {
581 continue587 continue
582 }588 }
583 observation := host.observation589 observation := host.observation
584- if !pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityIncompatible && !pickerObservationVersionMismatch(observation) {590+ hosts = append(hosts, pickerHostHealth{key: key, observation: observation, health: domain.RemoteHealth{
585- continue591+ Availability: observation.Availability,
586- }592+ VersionMismatch: pickerObservationVersionMismatch(observation),
587- failures = append(failures, pickerHostFailure{593+ Episode: observation.FailureEpisode,
588- key: key,594+ }})
589- episode: observation.FailureEpisode,
590- message: pickerHostFailureMessage(observation),
591- })
592 }595 }
593- return failures596+ return hosts
597+}
598+
599+// pickerHostHealthNotice renders one host event as a toast scoped to the host,
600+// so a recovery replaces the visible failure.
601+func pickerHostHealthNotice(host pickerHostHealth, event domain.RemoteHealthEvent) domain.Notification {
602+ n := domain.Notification{Code: domain.NoticeRemoteObservation, Scope: host.key}
603+ origin := pickerOriginLabel(host.observation)
604+ if event == domain.RemoteHealthRecovered {
605+ n.Severity = domain.NoticeInfo
606+ n.Message = "Remote host reconnected: " + origin
607+ return n
608+ }
609+ n.Severity = domain.NoticeError
610+ if host.observation.Availability == domain.RemoteAvailabilityNoDaemon {
611+ n.Severity = domain.NoticeWarn
612+ }
613+ n.Message = "Remote check failed: " + origin + " — " + pickerHostFailureText(host.observation, host.health.VersionMismatch)
614+ return n
594 }615 }
595 616
596-// pickerHostFailureMessage is main's remoteFailureNoticeMessage, named by the617+func pickerHostFailureText(observation ports.BrokerDaemonObservation, versionMismatch bool) string {
597-// host's display origin rather than a raw endpoint.618+ if observation.Availability == domain.RemoteAvailabilityIncompatible || versionMismatch {
598-func pickerHostFailureMessage(observation ports.BrokerDaemonObservation) string {619+ return "remote vev version is incompatible"
599- prefix := "Remote check failed: " + pickerOriginLabel(observation) + " — "
600- if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) {
601- return prefix + "remote vev version is incompatible"
602 }620 }
603 switch observation.LastFailure.Kind {621 switch observation.LastFailure.Kind {
604 case domain.RemoteFailureAuthentication:622 case domain.RemoteFailureAuthentication:
605- return prefix + "SSH authentication failed; verify non-interactive SSH access"623+ return "SSH authentication failed; verify non-interactive SSH access"
606 case domain.RemoteFailureTrust:624 case domain.RemoteFailureTrust:
607- return prefix + "SSH host verification failed; verify the host key policy"625+ return "SSH host verification failed; verify the host key policy"
608 case domain.RemoteFailureIncompatible:626 case domain.RemoteFailureIncompatible:
609- return prefix + "remote vev version is incompatible"627+ return "remote vev version is incompatible"
610 case domain.RemoteFailureInvalidResponse:628 case domain.RemoteFailureInvalidResponse:
611- return prefix + "remote catalog response is invalid"629+ return "remote catalog response is invalid"
612 case domain.RemoteFailureTimeout:630 case domain.RemoteFailureTimeout:
613- return prefix + "SSH timed out"631+ return "SSH timed out"
614 }632 }
615 switch observation.Availability {633 switch observation.Availability {
616 case domain.RemoteAvailabilityAuthFailed:634 case domain.RemoteAvailabilityAuthFailed:
617- return prefix + "SSH authentication failed; verify non-interactive SSH access"635+ return "SSH authentication failed; verify non-interactive SSH access"
618 case domain.RemoteAvailabilityInvalidResponse:636 case domain.RemoteAvailabilityInvalidResponse:
619- return prefix + "remote catalog response is invalid"637+ return "remote catalog response is invalid"
638+ case domain.RemoteAvailabilityNoDaemon:
639+ return "no vev daemon is running"
620 default:640 default:
621- return prefix + "SSH connection failed; verify SSH access"641+ return "SSH connection failed; verify SSH access"
622 }642 }
623 }643 }
M internal/usecase/client/picker_catalogue_test.go +63 -5
@@ -124,6 +124,15 @@ func pickerLineByLabel(lines []protocol.PickerLine, label string) (protocol.Pick
124124 return protocol.PickerLine{}, false
125125 }
126126
127+func pickerSectionByLabel(lines []protocol.PickerLine, label string) (protocol.PickerLine, bool) {
128+ for _, line := range lines {
129+ if line.Kind == protocol.PickerLineSection && line.Label == label {
130+ return line, true
131+ }
132+ }
133+ return protocol.PickerLine{}, false
134+}
135+
127136 func pickerHostLine(lines []protocol.PickerLine) (protocol.PickerLine, bool) {
128137 for _, line := range lines {
129138 if line.Kind == protocol.PickerLineHost {
@@ -187,6 +196,7 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
187196 wantSessions []string
188197 wantActions map[string]protocol.PickerLineActions
189198 wantStatus map[string]protocol.PickerLineStatus
199+ wantSection map[string]protocol.PickerLineStatus
190200 wantDim map[string]bool
191201 wantHostRow bool
192202 wantNoDaemon bool
@@ -244,7 +254,8 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
244254 wantSections: []string{"local"},
245255 wantSessions: []string{"alpha"},
246256 wantActions: map[string]protocol.PickerLineActions{"alpha": navigateKill},
247- wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusUp},
257+ wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusNone},
258+ wantSection: map[string]protocol.PickerLineStatus{"local": protocol.PickerLineStatusNone},
248259 wantDim: map[string]bool{"alpha": true},
249260 },
250261 {
@@ -259,7 +270,8 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
259270 wantSections: []string{"user@arch"},
260271 wantSessions: []string{"remote-a"},
261272 wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate},
262- wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp},
273+ wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusNone},
274+ wantSection: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusDown},
263275 wantDim: map[string]bool{"remote-a": true},
264276 },
265277 {
@@ -274,6 +286,7 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
274286 wantSections: []string{"user@arch"},
275287 wantSessions: []string{},
276288 wantStatus: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusDown},
289+ wantSection: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusNone},
277290 wantHostRow: true,
278291 },
279292 {
@@ -288,7 +301,8 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
288301 wantSections: []string{"user@arch"},
289302 wantSessions: []string{"remote-a"},
290303 wantActions: map[string]protocol.PickerLineActions{"remote-a": 0},
291- wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp},
304+ wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusNone},
305+ wantSection: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusVersion},
292306 wantDim: map[string]bool{"remote-a": true},
293307 },
294308 {
@@ -328,6 +342,11 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
328342 require.True(t, ok, "row %q must be projected", label)
329343 require.Equal(t, status, line.Status, "row %q badge", label)
330344 }
345+ for label, status := range tt.wantSection {
346+ line, ok := pickerSectionByLabel(lines, label)
347+ require.True(t, ok, "section %q must be projected", label)
348+ require.Equal(t, status, line.Status, "section %q problem dot", label)
349+ }
331350 for label, dim := range tt.wantDim {
332351 line, ok := pickerLineByLabel(lines, label)
333352 require.True(t, ok)
@@ -820,11 +839,11 @@ func TestPickerCatalogueAvailabilityClassifiedBeforeCompatibility(t *testing.T)
820839 o.ProtocolVersion = protocol.Version + 1
821840 }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable},
822841 {name: "reachable mismatch is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.ProtocolVersion = protocol.Version + 1 }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch},
823- {name: "reachable compatible fresh is up", observation: reachable(nil), fresh: true, wantStatus: protocol.PickerLineStatusUp, wantReason: ""},
842+ {name: "reachable compatible fresh is up", observation: reachable(nil), fresh: true, wantStatus: protocol.PickerLineStatusNone, wantReason: ""},
824843 {name: "reachable compatible stale is stale", observation: reachable(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonCatalogStale},
825844 {name: "reachable unobserved is stale and refreshing", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityReachable }), fresh: true, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing},
826845 {name: "legacy incompatible availability is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityIncompatible }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch},
827- {name: "reachable checking is up and refreshing", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Checking = true }), fresh: true, wantStatus: protocol.PickerLineStatusUp, wantReason: domain.RemoteReasonRefreshing},
846+ {name: "reachable checking is up and refreshing", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Checking = true }), fresh: true, wantStatus: protocol.PickerLineStatusNone, wantReason: domain.RemoteReasonRefreshing},
828847 }
829848 for _, tt := range tests {
830849 t.Run(tt.name, func(t *testing.T) {
@@ -1049,3 +1068,42 @@ func TestPickerCatalogueOriginLabelNeverShowsRawEndpoint(t *testing.T) {
10491068 require.Equal(t, "local", pickerOriginLabel(pickerTestUnobservedObservation(time.Unix(1000, 0))))
10501069 require.Equal(t, "user@arch", pickerOriginLabel(pickerTestRemoteObservation("user@arch", 1, 1, time.Unix(1000, 0))))
10511070 }
1071+
1072+func TestPickerHostHealthNotice(t *testing.T) {
1073+ now := time.Unix(1000, 0)
1074+ host := func(availability domain.RemoteAvailability, kind domain.RemoteFailureKind, mismatch bool) pickerHostHealth {
1075+ o := pickerTestRemoteObservation("user@arch", 1, 1, now)
1076+ o.Availability = availability
1077+ o.LastFailure = domain.RemoteFailure{Kind: kind}
1078+ return pickerHostHealth{key: "host-key", observation: o, health: domain.RemoteHealth{Availability: availability, VersionMismatch: mismatch}}
1079+ }
1080+ const failed = "Remote check failed: user@arch — "
1081+ tests := []struct {
1082+ name string
1083+ host pickerHostHealth
1084+ event domain.RemoteHealthEvent
1085+ severity domain.NoticeSeverity
1086+ message string
1087+ }{
1088+ {name: "recovered", host: host(domain.RemoteAvailabilityReachable, 0, false), event: domain.RemoteHealthRecovered, severity: domain.NoticeInfo, message: "Remote host reconnected: user@arch"},
1089+ {name: "timeout", host: host(domain.RemoteAvailabilityUnreachable, domain.RemoteFailureTimeout, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH timed out"},
1090+ {name: "authentication", host: host(domain.RemoteAvailabilityUnreachable, domain.RemoteFailureAuthentication, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH authentication failed; verify non-interactive SSH access"},
1091+ {name: "trust", host: host(domain.RemoteAvailabilityUnreachable, domain.RemoteFailureTrust, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH host verification failed; verify the host key policy"},
1092+ {name: "invalid response kind", host: host(domain.RemoteAvailabilityUnreachable, domain.RemoteFailureInvalidResponse, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "remote catalog response is invalid"},
1093+ {name: "auth failed availability fallback", host: host(domain.RemoteAvailabilityAuthFailed, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH authentication failed; verify non-interactive SSH access"},
1094+ {name: "invalid response availability fallback", host: host(domain.RemoteAvailabilityInvalidResponse, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "remote catalog response is invalid"},
1095+ {name: "unreachable default", host: host(domain.RemoteAvailabilityUnreachable, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH connection failed; verify SSH access"},
1096+ {name: "incompatible", host: host(domain.RemoteAvailabilityIncompatible, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "remote vev version is incompatible"},
1097+ {name: "version mismatch", host: host(domain.RemoteAvailabilityReachable, 0, true), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "remote vev version is incompatible"},
1098+ {name: "no daemon warns", host: host(domain.RemoteAvailabilityNoDaemon, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeWarn, message: failed + "no vev daemon is running"},
1099+ }
1100+ for _, tt := range tests {
1101+ t.Run(tt.name, func(t *testing.T) {
1102+ n := pickerHostHealthNotice(tt.host, tt.event)
1103+ require.Equal(t, domain.NoticeRemoteObservation, n.Code)
1104+ require.Equal(t, "host-key", n.Scope, "failure and recovery share the host subject")
1105+ require.Equal(t, tt.severity, n.Severity)
1106+ require.Equal(t, tt.message, n.Message)
1107+ })
1108+ }
1109+}
@@ -124,6 +124,15 @@ func pickerLineByLabel(lines []protocol.PickerLine, label string) (protocol.Pick
124 return protocol.PickerLine{}, false124 return protocol.PickerLine{}, false
125 }125 }
126 126
127+func pickerSectionByLabel(lines []protocol.PickerLine, label string) (protocol.PickerLine, bool) {
128+ for _, line := range lines {
129+ if line.Kind == protocol.PickerLineSection && line.Label == label {
130+ return line, true
131+ }
132+ }
133+ return protocol.PickerLine{}, false
134+}
135+
127 func pickerHostLine(lines []protocol.PickerLine) (protocol.PickerLine, bool) {136 func pickerHostLine(lines []protocol.PickerLine) (protocol.PickerLine, bool) {
128 for _, line := range lines {137 for _, line := range lines {
129 if line.Kind == protocol.PickerLineHost {138 if line.Kind == protocol.PickerLineHost {
@@ -187,6 +196,7 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
187 wantSessions []string196 wantSessions []string
188 wantActions map[string]protocol.PickerLineActions197 wantActions map[string]protocol.PickerLineActions
189 wantStatus map[string]protocol.PickerLineStatus198 wantStatus map[string]protocol.PickerLineStatus
199+ wantSection map[string]protocol.PickerLineStatus
190 wantDim map[string]bool200 wantDim map[string]bool
191 wantHostRow bool201 wantHostRow bool
192 wantNoDaemon bool202 wantNoDaemon bool
@@ -244,7 +254,8 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
244 wantSections: []string{"local"},254 wantSections: []string{"local"},
245 wantSessions: []string{"alpha"},255 wantSessions: []string{"alpha"},
246 wantActions: map[string]protocol.PickerLineActions{"alpha": navigateKill},256 wantActions: map[string]protocol.PickerLineActions{"alpha": navigateKill},
247- wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusUp},257+ wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusNone},
258+ wantSection: map[string]protocol.PickerLineStatus{"local": protocol.PickerLineStatusNone},
248 wantDim: map[string]bool{"alpha": true},259 wantDim: map[string]bool{"alpha": true},
249 },260 },
250 {261 {
@@ -259,7 +270,8 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
259 wantSections: []string{"user@arch"},270 wantSections: []string{"user@arch"},
260 wantSessions: []string{"remote-a"},271 wantSessions: []string{"remote-a"},
261 wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate},272 wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate},
262- wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp},273+ wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusNone},
274+ wantSection: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusDown},
263 wantDim: map[string]bool{"remote-a": true},275 wantDim: map[string]bool{"remote-a": true},
264 },276 },
265 {277 {
@@ -274,6 +286,7 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
274 wantSections: []string{"user@arch"},286 wantSections: []string{"user@arch"},
275 wantSessions: []string{},287 wantSessions: []string{},
276 wantStatus: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusDown},288 wantStatus: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusDown},
289+ wantSection: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusNone},
277 wantHostRow: true,290 wantHostRow: true,
278 },291 },
279 {292 {
@@ -288,7 +301,8 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
288 wantSections: []string{"user@arch"},301 wantSections: []string{"user@arch"},
289 wantSessions: []string{"remote-a"},302 wantSessions: []string{"remote-a"},
290 wantActions: map[string]protocol.PickerLineActions{"remote-a": 0},303 wantActions: map[string]protocol.PickerLineActions{"remote-a": 0},
291- wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp},304+ wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusNone},
305+ wantSection: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusVersion},
292 wantDim: map[string]bool{"remote-a": true},306 wantDim: map[string]bool{"remote-a": true},
293 },307 },
294 {308 {
@@ -328,6 +342,11 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
328 require.True(t, ok, "row %q must be projected", label)342 require.True(t, ok, "row %q must be projected", label)
329 require.Equal(t, status, line.Status, "row %q badge", label)343 require.Equal(t, status, line.Status, "row %q badge", label)
330 }344 }
345+ for label, status := range tt.wantSection {
346+ line, ok := pickerSectionByLabel(lines, label)
347+ require.True(t, ok, "section %q must be projected", label)
348+ require.Equal(t, status, line.Status, "section %q problem dot", label)
349+ }
331 for label, dim := range tt.wantDim {350 for label, dim := range tt.wantDim {
332 line, ok := pickerLineByLabel(lines, label)351 line, ok := pickerLineByLabel(lines, label)
333 require.True(t, ok)352 require.True(t, ok)
@@ -820,11 +839,11 @@ func TestPickerCatalogueAvailabilityClassifiedBeforeCompatibility(t *testing.T)
820 o.ProtocolVersion = protocol.Version + 1839 o.ProtocolVersion = protocol.Version + 1
821 }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable},840 }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable},
822 {name: "reachable mismatch is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.ProtocolVersion = protocol.Version + 1 }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch},841 {name: "reachable mismatch is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.ProtocolVersion = protocol.Version + 1 }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch},
823- {name: "reachable compatible fresh is up", observation: reachable(nil), fresh: true, wantStatus: protocol.PickerLineStatusUp, wantReason: ""},842+ {name: "reachable compatible fresh is up", observation: reachable(nil), fresh: true, wantStatus: protocol.PickerLineStatusNone, wantReason: ""},
824 {name: "reachable compatible stale is stale", observation: reachable(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonCatalogStale},843 {name: "reachable compatible stale is stale", observation: reachable(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonCatalogStale},
825 {name: "reachable unobserved is stale and refreshing", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityReachable }), fresh: true, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing},844 {name: "reachable unobserved is stale and refreshing", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityReachable }), fresh: true, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing},
826 {name: "legacy incompatible availability is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityIncompatible }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch},845 {name: "legacy incompatible availability is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityIncompatible }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch},
827- {name: "reachable checking is up and refreshing", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Checking = true }), fresh: true, wantStatus: protocol.PickerLineStatusUp, wantReason: domain.RemoteReasonRefreshing},846+ {name: "reachable checking is up and refreshing", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Checking = true }), fresh: true, wantStatus: protocol.PickerLineStatusNone, wantReason: domain.RemoteReasonRefreshing},
828 }847 }
829 for _, tt := range tests {848 for _, tt := range tests {
830 t.Run(tt.name, func(t *testing.T) {849 t.Run(tt.name, func(t *testing.T) {
@@ -1049,3 +1068,42 @@ func TestPickerCatalogueOriginLabelNeverShowsRawEndpoint(t *testing.T) {
1049 require.Equal(t, "local", pickerOriginLabel(pickerTestUnobservedObservation(time.Unix(1000, 0))))1068 require.Equal(t, "local", pickerOriginLabel(pickerTestUnobservedObservation(time.Unix(1000, 0))))
1050 require.Equal(t, "user@arch", pickerOriginLabel(pickerTestRemoteObservation("user@arch", 1, 1, time.Unix(1000, 0))))1069 require.Equal(t, "user@arch", pickerOriginLabel(pickerTestRemoteObservation("user@arch", 1, 1, time.Unix(1000, 0))))
1051 }1070 }
1071+
1072+func TestPickerHostHealthNotice(t *testing.T) {
1073+ now := time.Unix(1000, 0)
1074+ host := func(availability domain.RemoteAvailability, kind domain.RemoteFailureKind, mismatch bool) pickerHostHealth {
1075+ o := pickerTestRemoteObservation("user@arch", 1, 1, now)
1076+ o.Availability = availability
1077+ o.LastFailure = domain.RemoteFailure{Kind: kind}
1078+ return pickerHostHealth{key: "host-key", observation: o, health: domain.RemoteHealth{Availability: availability, VersionMismatch: mismatch}}
1079+ }
1080+ const failed = "Remote check failed: user@arch — "
1081+ tests := []struct {
1082+ name string
1083+ host pickerHostHealth
1084+ event domain.RemoteHealthEvent
1085+ severity domain.NoticeSeverity
1086+ message string
1087+ }{
1088+ {name: "recovered", host: host(domain.RemoteAvailabilityReachable, 0, false), event: domain.RemoteHealthRecovered, severity: domain.NoticeInfo, message: "Remote host reconnected: user@arch"},
1089+ {name: "timeout", host: host(domain.RemoteAvailabilityUnreachable, domain.RemoteFailureTimeout, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH timed out"},
1090+ {name: "authentication", host: host(domain.RemoteAvailabilityUnreachable, domain.RemoteFailureAuthentication, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH authentication failed; verify non-interactive SSH access"},
1091+ {name: "trust", host: host(domain.RemoteAvailabilityUnreachable, domain.RemoteFailureTrust, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH host verification failed; verify the host key policy"},
1092+ {name: "invalid response kind", host: host(domain.RemoteAvailabilityUnreachable, domain.RemoteFailureInvalidResponse, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "remote catalog response is invalid"},
1093+ {name: "auth failed availability fallback", host: host(domain.RemoteAvailabilityAuthFailed, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH authentication failed; verify non-interactive SSH access"},
1094+ {name: "invalid response availability fallback", host: host(domain.RemoteAvailabilityInvalidResponse, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "remote catalog response is invalid"},
1095+ {name: "unreachable default", host: host(domain.RemoteAvailabilityUnreachable, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "SSH connection failed; verify SSH access"},
1096+ {name: "incompatible", host: host(domain.RemoteAvailabilityIncompatible, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "remote vev version is incompatible"},
1097+ {name: "version mismatch", host: host(domain.RemoteAvailabilityReachable, 0, true), event: domain.RemoteHealthFailed, severity: domain.NoticeError, message: failed + "remote vev version is incompatible"},
1098+ {name: "no daemon warns", host: host(domain.RemoteAvailabilityNoDaemon, 0, false), event: domain.RemoteHealthFailed, severity: domain.NoticeWarn, message: failed + "no vev daemon is running"},
1099+ }
1100+ for _, tt := range tests {
1101+ t.Run(tt.name, func(t *testing.T) {
1102+ n := pickerHostHealthNotice(tt.host, tt.event)
1103+ require.Equal(t, domain.NoticeRemoteObservation, n.Code)
1104+ require.Equal(t, "host-key", n.Scope, "failure and recovery share the host subject")
1105+ require.Equal(t, tt.severity, n.Severity)
1106+ require.Equal(t, tt.message, n.Message)
1107+ })
1108+ }
1109+}
M internal/usecase/client/picker_composition.go +3 -5
@@ -130,15 +130,13 @@ func (p *Picker) SetOwnsInput(owns bool) {
130130 p.controller.SetOwnsInput(owns)
131131 }
132132
133-// OfferNotice shows one bounded client-local notice on the picker.
134-func (p *Picker) OfferNotice(id, message string) {
133+// Notify shows one bounded client-local notice on the picker.
134+func (p *Picker) Notify(n domain.Notification) {
135135 if p != nil && p.controller != nil {
136- p.controller.offerNotice(id, message)
136+ p.controller.Notify(n)
137137 }
138138 }
139139
140-func (p *Picker) offerNotice(id, message string) { p.OfferNotice(id, message) }
141-
142140 // invalidatePresentation forces the next frame to redraw the whole box.
143141 func (p *Picker) invalidatePresentation() {
144142 if p != nil && p.controller != nil {
@@ -130,15 +130,13 @@ func (p *Picker) SetOwnsInput(owns bool) {
130 p.controller.SetOwnsInput(owns)130 p.controller.SetOwnsInput(owns)
131 }131 }
132 132
133-// OfferNotice shows one bounded client-local notice on the picker.133+// Notify shows one bounded client-local notice on the picker.
134-func (p *Picker) OfferNotice(id, message string) {134+func (p *Picker) Notify(n domain.Notification) {
135 if p != nil && p.controller != nil {135 if p != nil && p.controller != nil {
136- p.controller.offerNotice(id, message)136+ p.controller.Notify(n)
137 }137 }
138 }138 }
139 139
140-func (p *Picker) offerNotice(id, message string) { p.OfferNotice(id, message) }
141-
142 // invalidatePresentation forces the next frame to redraw the whole box.140 // invalidatePresentation forces the next frame to redraw the whole box.
143 func (p *Picker) invalidatePresentation() {141 func (p *Picker) invalidatePresentation() {
144 if p != nil && p.controller != nil {142 if p != nil && p.controller != nil {
M internal/usecase/client/picker_controller.go +40 -43
@@ -109,10 +109,10 @@ type pickerController struct {
109109 lastCommitKey string
110110 // lastKillKey is the catalogue key captured with a pending kill decision.
111111 lastKillKey string
112- // noticedFailures records the failure episode already toasted per remote
113- // host, so one outage toasts once (main's notifyNewRemoteFailures).
114- noticedFailures map[string]uint64
115- preview pickerusecase.Preview
112+ // hostHealth is the last remote host state seen, so each host transition
113+ // is judged once by domain.RemoteHealthNotice.
114+ hostHealth map[string]domain.RemoteHealth
115+ preview pickerusecase.Preview
116116 // flushStop cancels the armed lone-escape flush. It is non-nil exactly
117117 // while one flush is armed for the currently withheld prefix.
118118 flushStop chan struct{}
@@ -177,7 +177,7 @@ func (p *pickerController) ApplySnapshot(snapshot ports.BrokerSnapshot) {
177177 // notice instead of silently vanishing. A merely stale publication
178178 // validates and is ignored without a notice.
179179 if snapshot.Validate() != nil {
180- p.offerNotice("picker-snapshot-rejected", "broker catalogue update rejected")
180+ p.Notify(domain.Notification{Code: domain.NoticeInternal, Severity: domain.NoticeError, Message: "broker catalogue update rejected"})
181181 }
182182 return
183183 }
@@ -495,7 +495,7 @@ func (p *pickerController) RenderNotice(size domain.Size) []byte {
495495 return nil
496496 }
497497 var buffer bytes.Buffer
498- if _, err := drawClientToast(&buffer, size, newest.Message, newest.Anchor); err != nil {
498+ if _, err := drawClientToast(&buffer, size, newest.Message, newest.Anchor, newest.Severity); err != nil {
499499 return nil
500500 }
501501 return buffer.Bytes()
@@ -534,7 +534,7 @@ func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStre
534534 key, ok := p.displayedKey()
535535 if !ok {
536536 err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "no row is selected"}
537- p.offerNotice("picker-refusal", err.Error())
537+ p.Notify(pickerRefusalNotice(err))
538538 return ports.BrokerOpenStreamRequest{}, err
539539 }
540540 return p.ResolveKey(key, base)
@@ -549,7 +549,7 @@ func (p *pickerController) ResolveKey(key string, base pickerResolveBase) (ports
549549 if p == nil || p.catalogue == nil {
550550 err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
551551 if p != nil {
552- p.offerNotice("picker-refusal", err.Error())
552+ p.Notify(pickerRefusalNotice(err))
553553 }
554554 return ports.BrokerOpenStreamRequest{}, err
555555 }
@@ -562,14 +562,14 @@ func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase)
562562 if p == nil || p.catalogue == nil {
563563 err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
564564 if p != nil {
565- p.offerNotice("picker-refusal", err.Error())
565+ p.Notify(pickerRefusalNotice(err))
566566 }
567567 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
568568 }
569569 ref, ok := p.catalogue.Ref(key)
570570 if !ok {
571571 err := pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}
572- p.offerNotice("picker-refusal", pickerRefusalNotice(err))
572+ p.Notify(pickerRefusalNotice(err))
573573 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
574574 }
575575 var request ports.BrokerOpenStreamRequest
@@ -583,7 +583,7 @@ func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase)
583583 err = pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "this picker row is not a session destination"}
584584 }
585585 if err != nil {
586- p.offerNotice("picker-refusal", pickerRefusalNotice(err))
586+ p.Notify(pickerRefusalNotice(err))
587587 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
588588 }
589589 return request, tab, nil
@@ -596,7 +596,7 @@ func (p *pickerController) ResolveKill(key string) (pickerKillTarget, error) {
596596 }
597597 target, err := p.catalogue.ResolveKill(key)
598598 if err != nil {
599- p.offerNotice("picker-refusal", pickerRefusalNotice(err))
599+ p.Notify(pickerRefusalNotice(err))
600600 return pickerKillTarget{}, err
601601 }
602602 return target, nil
@@ -630,13 +630,14 @@ func (p *pickerController) rememberSortLocked() {
630630 }
631631 }
632632
633-// pickerRefusalNotice is the toast text for one resolution refusal.
634-func pickerRefusalNotice(err error) string {
633+// pickerRefusalNotice is the notice for one resolution refusal.
634+func pickerRefusalNotice(err error) domain.Notification {
635+ text := err.Error()
635636 var typed pickerCatalogueError
636637 if errors.As(err, &typed) {
637- return typed.noticeText()
638+ text = typed.noticeText()
638639 }
639- return err.Error()
640+ return domain.Notification{Code: domain.NoticeSessionUnavailable, Severity: domain.NoticeWarn, Message: text}
640641 }
641642
642643 // displayedKey names the row under the cursor, whether or not it admits an
@@ -719,44 +720,40 @@ func (p *pickerController) rebuildLocked() {
719720 p.loop.replaceLines(snapshot())
720721 }
721722
722-// offerDiagnosticsLocked toasts each remote host failure once per failure
723-// episode (main's notifyNewRemoteFailures). Ordinary observation progress and
724-// catalogue aging stay on the rows; a recovered host forgets its episode so a
725-// later outage toasts again.
723+// offerDiagnosticsLocked notices each remote host transition that
724+// domain.RemoteHealthTransition deems worth one toast: a new failure or a
725+// recovery. Ordinary observation progress stays on the rows.
726726 func (p *pickerController) offerDiagnosticsLocked() {
727- failing := make(map[string]struct{})
728- for _, failure := range p.catalogue.failures() {
729- failing[failure.key] = struct{}{}
730- if noticed, ok := p.noticedFailures[failure.key]; ok && noticed == failure.episode {
731- continue
732- }
733- if p.noticedFailures == nil {
734- p.noticedFailures = make(map[string]uint64)
735- }
736- p.noticedFailures[failure.key] = failure.episode
737- p.notices.Show(p.clock.Now(), ui.Toast{
738- ID: "picker-host:" + failure.key,
739- Message: failure.message,
740- Anchor: domain.AnchorTopRight,
741- Duration: pickerNoticeLifetime,
742- })
743- }
744- for key := range p.noticedFailures {
745- if _, ok := failing[key]; !ok {
746- delete(p.noticedFailures, key)
727+ current := make(map[string]domain.RemoteHealth)
728+ for _, host := range p.catalogue.remoteHealth() {
729+ prev, seen := p.hostHealth[host.key]
730+ next, event := domain.RemoteHealthTransition(prev, seen, host.health)
731+ current[host.key] = next
732+ if event != domain.RemoteHealthSilent {
733+ p.notifyLocked(pickerHostHealthNotice(host, event))
747734 }
748735 }
736+ p.hostHealth = current
749737 }
750738
751-func (p *pickerController) offerNotice(id, message string) {
752- if message == "" {
739+// Notify shows one client-local notice. Notices about the same subject
740+// (domain.Notification.Subject) replace each other.
741+func (p *pickerController) Notify(n domain.Notification) {
742+ if p == nil {
753743 return
754744 }
755745 p.mu.Lock()
756- p.notices.Show(p.clock.Now(), ui.Toast{ID: id, Message: message, Anchor: domain.AnchorTopRight, Duration: pickerNoticeLifetime})
746+ p.notifyLocked(n)
757747 p.mu.Unlock()
758748 }
759749
750+func (p *pickerController) notifyLocked(n domain.Notification) {
751+ if n.Message == "" {
752+ return
753+ }
754+ p.notices.Show(p.clock.Now(), ui.Toast{ID: n.Subject(), Message: n.Message, Severity: n.Severity, Anchor: domain.AnchorTopRight, Duration: pickerNoticeLifetime})
755+}
756+
760757 // mergePickerOps accumulates two presentation decisions from one read. Close
761758 // wins because it retires the interaction; commit and kill both remain.
762759 func mergePickerOps(a, b pickerOp) pickerOp {
@@ -109,10 +109,10 @@ type pickerController struct {
109 lastCommitKey string109 lastCommitKey string
110 // lastKillKey is the catalogue key captured with a pending kill decision.110 // lastKillKey is the catalogue key captured with a pending kill decision.
111 lastKillKey string111 lastKillKey string
112- // noticedFailures records the failure episode already toasted per remote112+ // hostHealth is the last remote host state seen, so each host transition
113- // host, so one outage toasts once (main's notifyNewRemoteFailures).113+ // is judged once by domain.RemoteHealthNotice.
114- noticedFailures map[string]uint64114+ hostHealth map[string]domain.RemoteHealth
115- preview pickerusecase.Preview115+ preview pickerusecase.Preview
116 // flushStop cancels the armed lone-escape flush. It is non-nil exactly116 // flushStop cancels the armed lone-escape flush. It is non-nil exactly
117 // while one flush is armed for the currently withheld prefix.117 // while one flush is armed for the currently withheld prefix.
118 flushStop chan struct{}118 flushStop chan struct{}
@@ -177,7 +177,7 @@ func (p *pickerController) ApplySnapshot(snapshot ports.BrokerSnapshot) {
177 // notice instead of silently vanishing. A merely stale publication177 // notice instead of silently vanishing. A merely stale publication
178 // validates and is ignored without a notice.178 // validates and is ignored without a notice.
179 if snapshot.Validate() != nil {179 if snapshot.Validate() != nil {
180- p.offerNotice("picker-snapshot-rejected", "broker catalogue update rejected")180+ p.Notify(domain.Notification{Code: domain.NoticeInternal, Severity: domain.NoticeError, Message: "broker catalogue update rejected"})
181 }181 }
182 return182 return
183 }183 }
@@ -495,7 +495,7 @@ func (p *pickerController) RenderNotice(size domain.Size) []byte {
495 return nil495 return nil
496 }496 }
497 var buffer bytes.Buffer497 var buffer bytes.Buffer
498- if _, err := drawClientToast(&buffer, size, newest.Message, newest.Anchor); err != nil {498+ if _, err := drawClientToast(&buffer, size, newest.Message, newest.Anchor, newest.Severity); err != nil {
499 return nil499 return nil
500 }500 }
501 return buffer.Bytes()501 return buffer.Bytes()
@@ -534,7 +534,7 @@ func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStre
534 key, ok := p.displayedKey()534 key, ok := p.displayedKey()
535 if !ok {535 if !ok {
536 err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "no row is selected"}536 err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "no row is selected"}
537- p.offerNotice("picker-refusal", err.Error())537+ p.Notify(pickerRefusalNotice(err))
538 return ports.BrokerOpenStreamRequest{}, err538 return ports.BrokerOpenStreamRequest{}, err
539 }539 }
540 return p.ResolveKey(key, base)540 return p.ResolveKey(key, base)
@@ -549,7 +549,7 @@ func (p *pickerController) ResolveKey(key string, base pickerResolveBase) (ports
549 if p == nil || p.catalogue == nil {549 if p == nil || p.catalogue == nil {
550 err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}550 err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
551 if p != nil {551 if p != nil {
552- p.offerNotice("picker-refusal", err.Error())552+ p.Notify(pickerRefusalNotice(err))
553 }553 }
554 return ports.BrokerOpenStreamRequest{}, err554 return ports.BrokerOpenStreamRequest{}, err
555 }555 }
@@ -562,14 +562,14 @@ func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase)
562 if p == nil || p.catalogue == nil {562 if p == nil || p.catalogue == nil {
563 err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}563 err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
564 if p != nil {564 if p != nil {
565- p.offerNotice("picker-refusal", err.Error())565+ p.Notify(pickerRefusalNotice(err))
566 }566 }
567 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err567 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
568 }568 }
569 ref, ok := p.catalogue.Ref(key)569 ref, ok := p.catalogue.Ref(key)
570 if !ok {570 if !ok {
571 err := pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}571 err := pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}
572- p.offerNotice("picker-refusal", pickerRefusalNotice(err))572+ p.Notify(pickerRefusalNotice(err))
573 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err573 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
574 }574 }
575 var request ports.BrokerOpenStreamRequest575 var request ports.BrokerOpenStreamRequest
@@ -583,7 +583,7 @@ func (p *pickerController) ResolveKeyTarget(key string, base pickerResolveBase)
583 err = pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "this picker row is not a session destination"}583 err = pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "this picker row is not a session destination"}
584 }584 }
585 if err != nil {585 if err != nil {
586- p.offerNotice("picker-refusal", pickerRefusalNotice(err))586+ p.Notify(pickerRefusalNotice(err))
587 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err587 return ports.BrokerOpenStreamRequest{}, attachmentTab{}, err
588 }588 }
589 return request, tab, nil589 return request, tab, nil
@@ -596,7 +596,7 @@ func (p *pickerController) ResolveKill(key string) (pickerKillTarget, error) {
596 }596 }
597 target, err := p.catalogue.ResolveKill(key)597 target, err := p.catalogue.ResolveKill(key)
598 if err != nil {598 if err != nil {
599- p.offerNotice("picker-refusal", pickerRefusalNotice(err))599+ p.Notify(pickerRefusalNotice(err))
600 return pickerKillTarget{}, err600 return pickerKillTarget{}, err
601 }601 }
602 return target, nil602 return target, nil
@@ -630,13 +630,14 @@ func (p *pickerController) rememberSortLocked() {
630 }630 }
631 }631 }
632 632
633-// pickerRefusalNotice is the toast text for one resolution refusal.633+// pickerRefusalNotice is the notice for one resolution refusal.
634-func pickerRefusalNotice(err error) string {634+func pickerRefusalNotice(err error) domain.Notification {
635+ text := err.Error()
635 var typed pickerCatalogueError636 var typed pickerCatalogueError
636 if errors.As(err, &typed) {637 if errors.As(err, &typed) {
637- return typed.noticeText()638+ text = typed.noticeText()
638 }639 }
639- return err.Error()640+ return domain.Notification{Code: domain.NoticeSessionUnavailable, Severity: domain.NoticeWarn, Message: text}
640 }641 }
641 642
642 // displayedKey names the row under the cursor, whether or not it admits an643 // displayedKey names the row under the cursor, whether or not it admits an
@@ -719,44 +720,40 @@ func (p *pickerController) rebuildLocked() {
719 p.loop.replaceLines(snapshot())720 p.loop.replaceLines(snapshot())
720 }721 }
721 722
722-// offerDiagnosticsLocked toasts each remote host failure once per failure723+// offerDiagnosticsLocked notices each remote host transition that
723-// episode (main's notifyNewRemoteFailures). Ordinary observation progress and724+// domain.RemoteHealthTransition deems worth one toast: a new failure or a
724-// catalogue aging stay on the rows; a recovered host forgets its episode so a725+// recovery. Ordinary observation progress stays on the rows.
725-// later outage toasts again.
726 func (p *pickerController) offerDiagnosticsLocked() {726 func (p *pickerController) offerDiagnosticsLocked() {
727- failing := make(map[string]struct{})727+ current := make(map[string]domain.RemoteHealth)
728- for _, failure := range p.catalogue.failures() {728+ for _, host := range p.catalogue.remoteHealth() {
729- failing[failure.key] = struct{}{}729+ prev, seen := p.hostHealth[host.key]
730- if noticed, ok := p.noticedFailures[failure.key]; ok && noticed == failure.episode {730+ next, event := domain.RemoteHealthTransition(prev, seen, host.health)
731- continue731+ current[host.key] = next
732- }732+ if event != domain.RemoteHealthSilent {
733- if p.noticedFailures == nil {733+ p.notifyLocked(pickerHostHealthNotice(host, event))
734- p.noticedFailures = make(map[string]uint64)
735- }
736- p.noticedFailures[failure.key] = failure.episode
737- p.notices.Show(p.clock.Now(), ui.Toast{
738- ID: "picker-host:" + failure.key,
739- Message: failure.message,
740- Anchor: domain.AnchorTopRight,
741- Duration: pickerNoticeLifetime,
742- })
743- }
744- for key := range p.noticedFailures {
745- if _, ok := failing[key]; !ok {
746- delete(p.noticedFailures, key)
747 }734 }
748 }735 }
736+ p.hostHealth = current
749 }737 }
750 738
751-func (p *pickerController) offerNotice(id, message string) {739+// Notify shows one client-local notice. Notices about the same subject
752- if message == "" {740+// (domain.Notification.Subject) replace each other.
741+func (p *pickerController) Notify(n domain.Notification) {
742+ if p == nil {
753 return743 return
754 }744 }
755 p.mu.Lock()745 p.mu.Lock()
756- p.notices.Show(p.clock.Now(), ui.Toast{ID: id, Message: message, Anchor: domain.AnchorTopRight, Duration: pickerNoticeLifetime})746+ p.notifyLocked(n)
757 p.mu.Unlock()747 p.mu.Unlock()
758 }748 }
759 749
750+func (p *pickerController) notifyLocked(n domain.Notification) {
751+ if n.Message == "" {
752+ return
753+ }
754+ p.notices.Show(p.clock.Now(), ui.Toast{ID: n.Subject(), Message: n.Message, Severity: n.Severity, Anchor: domain.AnchorTopRight, Duration: pickerNoticeLifetime})
755+}
756+
760 // mergePickerOps accumulates two presentation decisions from one read. Close757 // mergePickerOps accumulates two presentation decisions from one read. Close
761 // wins because it retires the interaction; commit and kill both remain.758 // wins because it retires the interaction; commit and kill both remain.
762 func mergePickerOps(a, b pickerOp) pickerOp {759 func mergePickerOps(a, b pickerOp) pickerOp {
M internal/usecase/client/picker_parity_test.go +44 -3
@@ -506,6 +506,35 @@ func TestPickerControllerKillKeyWakesSupervisor(t *testing.T) {
506506 }
507507 }
508508
509+// TestPickerControllerRecoveryReplacesVisibleFailure checks that a host's
510+// recovery toast replaces its failure toast and leaves other hosts' toasts.
511+func TestPickerControllerRecoveryReplacesVisibleFailure(t *testing.T) {
512+ controller, clock := pickerTestController(t)
513+ down := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now())
514+ down.Availability = domain.RemoteAvailabilityUnreachable
515+ down.FailureEpisode = 1
516+ other := pickerTestRemoteObservation("user@mule", 2, 2, clock.Now())
517+ other.Availability = domain.RemoteAvailabilityUnreachable
518+ other.FailureEpisode = 1
519+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{down, other}})
520+
521+ up := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now())
522+ up.FailureEpisode = 1
523+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{up, other}})
524+
525+ controller.mu.Lock()
526+ defer controller.mu.Unlock()
527+ messages := make([]string, 0)
528+ for _, toast := range controller.notices.Active(clock.Now()) {
529+ messages = append(messages, toast.Message)
530+ }
531+ require.Len(t, messages, 2, "each host keeps one toast")
532+ require.Contains(t, messages, "Remote host reconnected: user@arch")
533+ for _, message := range messages {
534+ require.NotContains(t, message, "Remote check failed: user@arch", "the recovery replaced the failure")
535+ }
536+}
537+
509538 // TestPickerControllerHostFailureToastOncePerEpisode ports main's
510539 // TestRemoteFailureNoticeEmittedOncePerFailureEpisode and
511540 // TestRemoteFailureNoticesKeepEndpointsDistinct.
@@ -540,10 +569,22 @@ func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) {
540569
541570 recovered := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(time.Second))
542571 recovered.FailureEpisode = 2
543- require.Empty(t, apply(recovered))
544- require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1, "an outage after recovery toasts again")
572+ toasts = apply(recovered)
573+ require.Len(t, toasts, 1, "a recovery toasts once")
574+ require.Equal(t, "Remote host reconnected: user@arch", toasts[0].Message)
575+ require.Empty(t, apply(recovered), "a steady healthy host is silent")
576+
577+ require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1)
578+ unknown := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(2*time.Second))
579+ unknown.Availability = domain.RemoteAvailabilityUnknown
580+ unknown.FailureEpisode = 3
581+ require.Empty(t, apply(unknown), "an in-between state is silent")
582+ recovered = pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(3*time.Second))
583+ recovered.FailureEpisode = 3
584+ require.Len(t, apply(recovered), 1, "failing → unknown → reachable still reports recovery")
585+ require.Len(t, apply(failing("user@arch", 1, 4, domain.RemoteFailureTimeout)), 1, "an outage after recovery toasts again")
545586
546- toasts = apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication))
587+ toasts = apply(failing("user@arch", 1, 4, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication))
547588 require.Len(t, toasts, 1, "only the newly failing endpoint toasts")
548589 require.Contains(t, toasts[0].Message, "user@mule")
549590 }
@@ -506,6 +506,35 @@ func TestPickerControllerKillKeyWakesSupervisor(t *testing.T) {
506 }506 }
507 }507 }
508 508
509+// TestPickerControllerRecoveryReplacesVisibleFailure checks that a host's
510+// recovery toast replaces its failure toast and leaves other hosts' toasts.
511+func TestPickerControllerRecoveryReplacesVisibleFailure(t *testing.T) {
512+ controller, clock := pickerTestController(t)
513+ down := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now())
514+ down.Availability = domain.RemoteAvailabilityUnreachable
515+ down.FailureEpisode = 1
516+ other := pickerTestRemoteObservation("user@mule", 2, 2, clock.Now())
517+ other.Availability = domain.RemoteAvailabilityUnreachable
518+ other.FailureEpisode = 1
519+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{down, other}})
520+
521+ up := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now())
522+ up.FailureEpisode = 1
523+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{up, other}})
524+
525+ controller.mu.Lock()
526+ defer controller.mu.Unlock()
527+ messages := make([]string, 0)
528+ for _, toast := range controller.notices.Active(clock.Now()) {
529+ messages = append(messages, toast.Message)
530+ }
531+ require.Len(t, messages, 2, "each host keeps one toast")
532+ require.Contains(t, messages, "Remote host reconnected: user@arch")
533+ for _, message := range messages {
534+ require.NotContains(t, message, "Remote check failed: user@arch", "the recovery replaced the failure")
535+ }
536+}
537+
509 // TestPickerControllerHostFailureToastOncePerEpisode ports main's538 // TestPickerControllerHostFailureToastOncePerEpisode ports main's
510 // TestRemoteFailureNoticeEmittedOncePerFailureEpisode and539 // TestRemoteFailureNoticeEmittedOncePerFailureEpisode and
511 // TestRemoteFailureNoticesKeepEndpointsDistinct.540 // TestRemoteFailureNoticesKeepEndpointsDistinct.
@@ -540,10 +569,22 @@ func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) {
540 569
541 recovered := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(time.Second))570 recovered := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(time.Second))
542 recovered.FailureEpisode = 2571 recovered.FailureEpisode = 2
543- require.Empty(t, apply(recovered))572+ toasts = apply(recovered)
544- require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1, "an outage after recovery toasts again")573+ require.Len(t, toasts, 1, "a recovery toasts once")
574+ require.Equal(t, "Remote host reconnected: user@arch", toasts[0].Message)
575+ require.Empty(t, apply(recovered), "a steady healthy host is silent")
576+
577+ require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1)
578+ unknown := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(2*time.Second))
579+ unknown.Availability = domain.RemoteAvailabilityUnknown
580+ unknown.FailureEpisode = 3
581+ require.Empty(t, apply(unknown), "an in-between state is silent")
582+ recovered = pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(3*time.Second))
583+ recovered.FailureEpisode = 3
584+ require.Len(t, apply(recovered), 1, "failing → unknown → reachable still reports recovery")
585+ require.Len(t, apply(failing("user@arch", 1, 4, domain.RemoteFailureTimeout)), 1, "an outage after recovery toasts again")
545 586
546- toasts = apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication))587+ toasts = apply(failing("user@arch", 1, 4, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication))
547 require.Len(t, toasts, 1, "only the newly failing endpoint toasts")588 require.Len(t, toasts, 1, "only the newly failing endpoint toasts")
548 require.Contains(t, toasts[0].Message, "user@mule")589 require.Contains(t, toasts[0].Message, "user@mule")
549 }590 }
D internal/usecase/client/reconnect_toast_test.go +0 -37
@@ -1,37 +0,0 @@
1-package client
2-
3-import (
4- "bytes"
5- "testing"
6-
7- "github.com/stretchr/testify/require"
8-
9- "github.com/bnema/vev/internal/domain"
10-)
11-
12-func TestDrawClientToastPlacement(t *testing.T) {
13- size := domain.Size{Cols: 100, Rows: 30}
14- tests := []struct {
15- name string
16- anchor domain.Anchor
17- check func(t *testing.T, bounds domain.Rect)
18- }{
19- {name: "notice sits top-right", anchor: domain.AnchorTopRight, check: func(t *testing.T, bounds domain.Rect) {
20- require.Less(t, bounds.Y, 3)
21- require.Greater(t, bounds.X+bounds.Width, size.Cols-3)
22- }},
23- {name: "transition stays centered", anchor: domain.AnchorCenter, check: func(t *testing.T, bounds domain.Rect) {
24- require.Equal(t, (size.Rows-bounds.Height)/2, bounds.Y)
25- require.Equal(t, (size.Cols-bounds.Width)/2, bounds.X)
26- }},
27- }
28- for _, tt := range tests {
29- t.Run(tt.name, func(t *testing.T) {
30- var out bytes.Buffer
31- bounds, err := drawClientToast(&out, size, "SSH authentication failed", tt.anchor)
32- require.NoError(t, err)
33- require.NotZero(t, out.Len())
34- tt.check(t, bounds)
35- })
36- }
37-}
@@ -1,37 +0,0 @@
1-package client
2-
3-import (
4- "bytes"
5- "testing"
6-
7- "github.com/stretchr/testify/require"
8-
9- "github.com/bnema/vev/internal/domain"
10-)
11-
12-func TestDrawClientToastPlacement(t *testing.T) {
13- size := domain.Size{Cols: 100, Rows: 30}
14- tests := []struct {
15- name string
16- anchor domain.Anchor
17- check func(t *testing.T, bounds domain.Rect)
18- }{
19- {name: "notice sits top-right", anchor: domain.AnchorTopRight, check: func(t *testing.T, bounds domain.Rect) {
20- require.Less(t, bounds.Y, 3)
21- require.Greater(t, bounds.X+bounds.Width, size.Cols-3)
22- }},
23- {name: "transition stays centered", anchor: domain.AnchorCenter, check: func(t *testing.T, bounds domain.Rect) {
24- require.Equal(t, (size.Rows-bounds.Height)/2, bounds.Y)
25- require.Equal(t, (size.Cols-bounds.Width)/2, bounds.X)
26- }},
27- }
28- for _, tt := range tests {
29- t.Run(tt.name, func(t *testing.T) {
30- var out bytes.Buffer
31- bounds, err := drawClientToast(&out, size, "SSH authentication failed", tt.anchor)
32- require.NoError(t, err)
33- require.NotZero(t, out.Len())
34- tt.check(t, bounds)
35- })
36- }
37-}
M internal/usecase/client/route_ledger.go +1 -1
@@ -354,7 +354,7 @@ func routeReachability(daemon ports.BrokerDaemonObservation) protocol.RouteReach
354354 switch {
355355 case daemon.Availability == domain.RemoteAvailabilityReachable:
356356 return protocol.RouteReachabilityReachable
357- case pickerObservationFailing(daemon) || daemon.Availability == domain.RemoteAvailabilityIncompatible:
357+ case daemon.Availability.Failing():
358358 return protocol.RouteReachabilityUnavailable
359359 default:
360360 return protocol.RouteReachabilityUnknown
@@ -354,7 +354,7 @@ func routeReachability(daemon ports.BrokerDaemonObservation) protocol.RouteReach
354 switch {354 switch {
355 case daemon.Availability == domain.RemoteAvailabilityReachable:355 case daemon.Availability == domain.RemoteAvailabilityReachable:
356 return protocol.RouteReachabilityReachable356 return protocol.RouteReachabilityReachable
357- case pickerObservationFailing(daemon) || daemon.Availability == domain.RemoteAvailabilityIncompatible:357+ case daemon.Availability.Failing():
358 return protocol.RouteReachabilityUnavailable358 return protocol.RouteReachabilityUnavailable
359 default:359 default:
360 return protocol.RouteReachabilityUnknown360 return protocol.RouteReachabilityUnknown
M internal/usecase/client/supervisor.go +11 -7
@@ -12,6 +12,7 @@ import (
1212 "sync/atomic"
1313 "time"
1414
15+ "github.com/bnema/vev/internal/domain"
1516 "github.com/bnema/vev/internal/ports"
1617 )
1718
@@ -930,7 +931,7 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim
930931 }
931932 // Escape and q cancel; with no attachment there is nothing to
932933 // cancel back to, so the picker stays and says how to leave.
933- s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit")
934+ s.notifyPicker(pickerExitHint)
934935 s.renderCurrent()
935936 continue
936937 }
@@ -978,12 +979,12 @@ func (s *Supervisor) takePickerClose() bool {
978979 s.pendingPickerKey = key
979980 }
980981 if op.close && !op.exit {
981- s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit")
982+ s.notifyPicker(pickerExitHint)
982983 s.renderCurrent()
983984 }
984985 if op.kill && !op.commit && !op.close && key != "" {
985986 // A kill needs the broker's control stream; it is refused, not queued.
986- s.offerPickerNotice("picker-kill", "couldn't kill: broker unavailable")
987+ s.notifyPicker(pickerKillRefused("couldn't kill: broker unavailable"))
987988 s.renderCurrent()
988989 }
989990 return op.close && op.exit
@@ -992,18 +993,21 @@ func (s *Supervisor) takePickerClose() bool {
992993 // pickerPresentationHost is the optional presentation surface of the real
993994 // picker. Scripted pickers may omit it.
994995 type pickerPresentationHost interface {
995- offerNotice(id, message string)
996+ Notify(n domain.Notification)
996997 invalidatePresentation()
997998 }
998999
999-// offerPickerNotice shows one bounded client-local notice when the picker
1000+// notifyPicker shows one bounded client-local notice when the picker
10001001 // supports it.
1001-func (s *Supervisor) offerPickerNotice(id, message string) {
1002+func (s *Supervisor) notifyPicker(n domain.Notification) {
10021003 if host, ok := s.cfg.Picker.(pickerPresentationHost); ok {
1003- host.offerNotice(id, message)
1004+ host.Notify(n)
10041005 }
10051006 }
10061007
1008+// pickerExitHint tells an unattached user how to leave the picker.
1009+var pickerExitHint = domain.Notification{Code: domain.NoticeUser, Severity: domain.NoticeInfo, Message: "no session attached: press Ctrl+C to quit"}
1010+
10071011 // invalidatePickerPresentation forces the next picker frame to redraw the
10081012 // whole box after another owner wrote the terminal.
10091013 func (s *Supervisor) invalidatePickerPresentation() {
@@ -12,6 +12,7 @@ import (
12 "sync/atomic"12 "sync/atomic"
13 "time"13 "time"
14 14
15+ "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 )17 )
17 18
@@ -930,7 +931,7 @@ func (s *Supervisor) awaitReady(ctx context.Context, input *terminalInputLifetim
930 }931 }
931 // Escape and q cancel; with no attachment there is nothing to932 // Escape and q cancel; with no attachment there is nothing to
932 // cancel back to, so the picker stays and says how to leave.933 // cancel back to, so the picker stays and says how to leave.
933- s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit")934+ s.notifyPicker(pickerExitHint)
934 s.renderCurrent()935 s.renderCurrent()
935 continue936 continue
936 }937 }
@@ -978,12 +979,12 @@ func (s *Supervisor) takePickerClose() bool {
978 s.pendingPickerKey = key979 s.pendingPickerKey = key
979 }980 }
980 if op.close && !op.exit {981 if op.close && !op.exit {
981- s.offerPickerNotice("picker-exit-hint", "no session attached: press Ctrl+C to quit")982+ s.notifyPicker(pickerExitHint)
982 s.renderCurrent()983 s.renderCurrent()
983 }984 }
984 if op.kill && !op.commit && !op.close && key != "" {985 if op.kill && !op.commit && !op.close && key != "" {
985 // A kill needs the broker's control stream; it is refused, not queued.986 // A kill needs the broker's control stream; it is refused, not queued.
986- s.offerPickerNotice("picker-kill", "couldn't kill: broker unavailable")987+ s.notifyPicker(pickerKillRefused("couldn't kill: broker unavailable"))
987 s.renderCurrent()988 s.renderCurrent()
988 }989 }
989 return op.close && op.exit990 return op.close && op.exit
@@ -992,18 +993,21 @@ func (s *Supervisor) takePickerClose() bool {
992 // pickerPresentationHost is the optional presentation surface of the real993 // pickerPresentationHost is the optional presentation surface of the real
993 // picker. Scripted pickers may omit it.994 // picker. Scripted pickers may omit it.
994 type pickerPresentationHost interface {995 type pickerPresentationHost interface {
995- offerNotice(id, message string)996+ Notify(n domain.Notification)
996 invalidatePresentation()997 invalidatePresentation()
997 }998 }
998 999
999-// offerPickerNotice shows one bounded client-local notice when the picker1000+// notifyPicker shows one bounded client-local notice when the picker
1000 // supports it.1001 // supports it.
1001-func (s *Supervisor) offerPickerNotice(id, message string) {1002+func (s *Supervisor) notifyPicker(n domain.Notification) {
1002 if host, ok := s.cfg.Picker.(pickerPresentationHost); ok {1003 if host, ok := s.cfg.Picker.(pickerPresentationHost); ok {
1003- host.offerNotice(id, message)1004+ host.Notify(n)
1004 }1005 }
1005 }1006 }
1006 1007
1008+// pickerExitHint tells an unattached user how to leave the picker.
1009+var pickerExitHint = domain.Notification{Code: domain.NoticeUser, Severity: domain.NoticeInfo, Message: "no session attached: press Ctrl+C to quit"}
1010+
1007 // invalidatePickerPresentation forces the next picker frame to redraw the1011 // invalidatePickerPresentation forces the next picker frame to redraw the
1008 // whole box after another owner wrote the terminal.1012 // whole box after another owner wrote the terminal.
1009 func (s *Supervisor) invalidatePickerPresentation() {1013 func (s *Supervisor) invalidatePickerPresentation() {
M internal/usecase/client/supervisor_picker_kill.go +19 -5
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "sync"
77
8+ "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/ports"
910 "github.com/bnema/vev/internal/protocol"
1011 )
@@ -69,7 +70,7 @@ func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) {
6970 return
7071 }
7172 if s.kills.running {
72- s.offerPickerNotice("picker-kill", "a kill is already in progress")
73+ s.notifyPicker(pickerKillRefused("a kill is already in progress"))
7374 return
7475 }
7576 target, err := resolver.ResolveKill(key)
@@ -78,7 +79,7 @@ func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) {
7879 }
7980 operations, err := NewBrokerOperations(service, s.cfg.Clock)
8081 if err != nil {
81- s.offerPickerNotice("picker-kill", "couldn't kill "+target.name+": broker unavailable")
82+ s.notifyPicker(pickerKillRefused("couldn't kill " + target.name + ": broker unavailable"))
8283 return
8384 }
8485 if s.kills.done == nil {
@@ -99,7 +100,7 @@ func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) {
99100 // the local daemon, so the killed row leaves the catalogue promptly.
100101 func (s *Supervisor) finishPickerKill(service ports.BrokerService, outcome pickerKillOutcome) {
101102 s.settlePickerKill()
102- s.offerPickerNotice("picker-kill", pickerKillNotice(outcome))
103+ s.notifyPicker(pickerKillNotice(outcome))
103104 if !supervisorNil(service) {
104105 service.RequestReconcile("")
105106 }
@@ -116,7 +117,7 @@ func (s *Supervisor) retirePickerKill() {
116117 s.kills.wg.Wait()
117118 outcome := <-s.kills.done
118119 s.settlePickerKill()
119- s.offerPickerNotice("picker-kill", pickerKillNotice(outcome))
120+ s.notifyPicker(pickerKillNotice(outcome))
120121 }
121122
122123 func (s *Supervisor) settlePickerKill() {
@@ -127,8 +128,21 @@ func (s *Supervisor) settlePickerKill() {
127128 s.kills.cancel = nil
128129 }
129130
131+// pickerKillRefused is a kill that was never sent.
132+func pickerKillRefused(message string) domain.Notification {
133+ return domain.Notification{Code: domain.NoticeSessionKill, Severity: domain.NoticeWarn, Message: message}
134+}
135+
130136 // pickerKillNotice renders one typed kill outcome.
131-func pickerKillNotice(outcome pickerKillOutcome) string {
137+func pickerKillNotice(outcome pickerKillOutcome) domain.Notification {
138+ sev := domain.NoticeError
139+ if outcome.err == nil && outcome.result.Outcome == protocol.KillSucceeded {
140+ sev = domain.NoticeInfo
141+ }
142+ return domain.Notification{Code: domain.NoticeSessionKill, Severity: sev, Message: pickerKillText(outcome)}
143+}
144+
145+func pickerKillText(outcome pickerKillOutcome) string {
132146 name := outcome.target.name
133147 verb, done := "kill", "killed session "
134148 if outcome.target.stopped {
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "sync"6 "sync"
7 7
8+ "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/protocol"10 "github.com/bnema/vev/internal/protocol"
10 )11 )
@@ -69,7 +70,7 @@ func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) {
69 return70 return
70 }71 }
71 if s.kills.running {72 if s.kills.running {
72- s.offerPickerNotice("picker-kill", "a kill is already in progress")73+ s.notifyPicker(pickerKillRefused("a kill is already in progress"))
73 return74 return
74 }75 }
75 target, err := resolver.ResolveKill(key)76 target, err := resolver.ResolveKill(key)
@@ -78,7 +79,7 @@ func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) {
78 }79 }
79 operations, err := NewBrokerOperations(service, s.cfg.Clock)80 operations, err := NewBrokerOperations(service, s.cfg.Clock)
80 if err != nil {81 if err != nil {
81- s.offerPickerNotice("picker-kill", "couldn't kill "+target.name+": broker unavailable")82+ s.notifyPicker(pickerKillRefused("couldn't kill " + target.name + ": broker unavailable"))
82 return83 return
83 }84 }
84 if s.kills.done == nil {85 if s.kills.done == nil {
@@ -99,7 +100,7 @@ func (s *Supervisor) startPickerKill(service ports.BrokerService, key string) {
99 // the local daemon, so the killed row leaves the catalogue promptly.100 // the local daemon, so the killed row leaves the catalogue promptly.
100 func (s *Supervisor) finishPickerKill(service ports.BrokerService, outcome pickerKillOutcome) {101 func (s *Supervisor) finishPickerKill(service ports.BrokerService, outcome pickerKillOutcome) {
101 s.settlePickerKill()102 s.settlePickerKill()
102- s.offerPickerNotice("picker-kill", pickerKillNotice(outcome))103+ s.notifyPicker(pickerKillNotice(outcome))
103 if !supervisorNil(service) {104 if !supervisorNil(service) {
104 service.RequestReconcile("")105 service.RequestReconcile("")
105 }106 }
@@ -116,7 +117,7 @@ func (s *Supervisor) retirePickerKill() {
116 s.kills.wg.Wait()117 s.kills.wg.Wait()
117 outcome := <-s.kills.done118 outcome := <-s.kills.done
118 s.settlePickerKill()119 s.settlePickerKill()
119- s.offerPickerNotice("picker-kill", pickerKillNotice(outcome))120+ s.notifyPicker(pickerKillNotice(outcome))
120 }121 }
121 122
122 func (s *Supervisor) settlePickerKill() {123 func (s *Supervisor) settlePickerKill() {
@@ -127,8 +128,21 @@ func (s *Supervisor) settlePickerKill() {
127 s.kills.cancel = nil128 s.kills.cancel = nil
128 }129 }
129 130
131+// pickerKillRefused is a kill that was never sent.
132+func pickerKillRefused(message string) domain.Notification {
133+ return domain.Notification{Code: domain.NoticeSessionKill, Severity: domain.NoticeWarn, Message: message}
134+}
135+
130 // pickerKillNotice renders one typed kill outcome.136 // pickerKillNotice renders one typed kill outcome.
131-func pickerKillNotice(outcome pickerKillOutcome) string {137+func pickerKillNotice(outcome pickerKillOutcome) domain.Notification {
138+ sev := domain.NoticeError
139+ if outcome.err == nil && outcome.result.Outcome == protocol.KillSucceeded {
140+ sev = domain.NoticeInfo
141+ }
142+ return domain.Notification{Code: domain.NoticeSessionKill, Severity: sev, Message: pickerKillText(outcome)}
143+}
144+
145+func pickerKillText(outcome pickerKillOutcome) string {
132 name := outcome.target.name146 name := outcome.target.name
133 verb, done := "kill", "killed session "147 verb, done := "kill", "killed session "
134 if outcome.target.stopped {148 if outcome.target.stopped {
M internal/usecase/client/supervisor_picker_parity_test.go +3 -2
@@ -10,6 +10,7 @@ import (
1010
1111 "github.com/stretchr/testify/require"
1212
13+ "github.com/bnema/vev/internal/domain"
1314 "github.com/bnema/vev/internal/ports"
1415 "github.com/bnema/vev/internal/protocol"
1516 )
@@ -45,9 +46,9 @@ func (p *parityTestPicker) ResolveKill(string) (pickerKillTarget, error) {
4546 return p.kill, nil
4647 }
4748
48-func (p *parityTestPicker) offerNotice(_ string, message string) {
49+func (p *parityTestPicker) Notify(n domain.Notification) {
4950 p.mu.Lock()
50- p.notices = append(p.notices, message)
51+ p.notices = append(p.notices, n.Message)
5152 p.mu.Unlock()
5253 }
5354
@@ -10,6 +10,7 @@ import (
10 10
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 12
13+ "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
14 "github.com/bnema/vev/internal/protocol"15 "github.com/bnema/vev/internal/protocol"
15 )16 )
@@ -45,9 +46,9 @@ func (p *parityTestPicker) ResolveKill(string) (pickerKillTarget, error) {
45 return p.kill, nil46 return p.kill, nil
46 }47 }
47 48
48-func (p *parityTestPicker) offerNotice(_ string, message string) {49+func (p *parityTestPicker) Notify(n domain.Notification) {
49 p.mu.Lock()50 p.mu.Lock()
50- p.notices = append(p.notices, message)51+ p.notices = append(p.notices, n.Message)
51 p.mu.Unlock()52 p.mu.Unlock()
52 }53 }
53 54
M internal/usecase/client/supervisor_routes.go +2 -1
@@ -3,6 +3,7 @@ package client
33 import (
44 "errors"
55
6+ "github.com/bnema/vev/internal/domain"
67 "github.com/bnema/vev/internal/ports"
78 "github.com/bnema/vev/internal/protocol"
89 )
@@ -169,7 +170,7 @@ func (s *Supervisor) resolveDaemonNavigation(service ports.BrokerService, token
169170 // revalidates the exact identity on Hello.
170171 func (s *Supervisor) resolveServingHandoff(service ports.BrokerService, request ports.BrokerOpenStreamRequest, handoff protocol.AttachTarget) (pickerAttachmentTarget, bool) {
171172 if handoff.Endpoint != "" || handoff.SessionTarget != nil || handoff.ExactTarget == nil || handoff.Intent != protocol.IntentAttach {
172- s.offerPickerNotice("daemon-handoff", "the daemon asked for a destination this client cannot open")
173+ s.notifyPicker(domain.Notification{Code: domain.NoticeSessionUnavailable, Scope: "handoff", Severity: domain.NoticeWarn, Message: "the daemon asked for a destination this client cannot open"})
173174 return pickerAttachmentTarget{}, false
174175 }
175176 stream, err := service.NextStreamID()
@@ -3,6 +3,7 @@ package client
3 import (3 import (
4 "errors"4 "errors"
5 5
6+ "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
7 "github.com/bnema/vev/internal/protocol"8 "github.com/bnema/vev/internal/protocol"
8 )9 )
@@ -169,7 +170,7 @@ func (s *Supervisor) resolveDaemonNavigation(service ports.BrokerService, token
169 // revalidates the exact identity on Hello.170 // revalidates the exact identity on Hello.
170 func (s *Supervisor) resolveServingHandoff(service ports.BrokerService, request ports.BrokerOpenStreamRequest, handoff protocol.AttachTarget) (pickerAttachmentTarget, bool) {171 func (s *Supervisor) resolveServingHandoff(service ports.BrokerService, request ports.BrokerOpenStreamRequest, handoff protocol.AttachTarget) (pickerAttachmentTarget, bool) {
171 if handoff.Endpoint != "" || handoff.SessionTarget != nil || handoff.ExactTarget == nil || handoff.Intent != protocol.IntentAttach {172 if handoff.Endpoint != "" || handoff.SessionTarget != nil || handoff.ExactTarget == nil || handoff.Intent != protocol.IntentAttach {
172- s.offerPickerNotice("daemon-handoff", "the daemon asked for a destination this client cannot open")173+ s.notifyPicker(domain.Notification{Code: domain.NoticeSessionUnavailable, Scope: "handoff", Severity: domain.NoticeWarn, Message: "the daemon asked for a destination this client cannot open"})
173 return pickerAttachmentTarget{}, false174 return pickerAttachmentTarget{}, false
174 }175 }
175 stream, err := service.NextStreamID()176 stream, err := service.NextStreamID()
M internal/usecase/client/transition_ui.go +1 -1
@@ -12,7 +12,7 @@ var transitionSpinnerFrames = [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴'
1212 // RenderTransitionNotice draws one connecting frame for the terminal owner.
1313 func RenderTransitionNotice(size domain.Size, frame int, message string) []byte {
1414 var out bytes.Buffer
15- if _, err := drawClientToast(&out, size, fmt.Sprintf("%c %s", transitionSpinnerFrames[frame%len(transitionSpinnerFrames)], message), domain.AnchorCenter); err != nil {
15+ if _, err := drawClientToast(&out, size, fmt.Sprintf("%c %s", transitionSpinnerFrames[frame%len(transitionSpinnerFrames)], message), domain.AnchorCenter, domain.NoticeInfo); err != nil {
1616 return nil
1717 }
1818 return out.Bytes()
@@ -12,7 +12,7 @@ var transitionSpinnerFrames = [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴'
12 // RenderTransitionNotice draws one connecting frame for the terminal owner.12 // RenderTransitionNotice draws one connecting frame for the terminal owner.
13 func RenderTransitionNotice(size domain.Size, frame int, message string) []byte {13 func RenderTransitionNotice(size domain.Size, frame int, message string) []byte {
14 var out bytes.Buffer14 var out bytes.Buffer
15- if _, err := drawClientToast(&out, size, fmt.Sprintf("%c %s", transitionSpinnerFrames[frame%len(transitionSpinnerFrames)], message), domain.AnchorCenter); err != nil {15+ if _, err := drawClientToast(&out, size, fmt.Sprintf("%c %s", transitionSpinnerFrames[frame%len(transitionSpinnerFrames)], message), domain.AnchorCenter, domain.NoticeInfo); err != nil {
16 return nil16 return nil
17 }17 }
18 return out.Bytes()18 return out.Bytes()
M internal/usecase/picker/model.go +33 -23
@@ -553,23 +553,27 @@ func (s SortMode) Title() string {
553553 return " Sessions · recent "
554554 }
555555
556-// statusDot is the one-cell state marker. Every state uses the same dot and
557-// a fixed xterm-256 color, so it reads the same in any terminal and theme:
558-// green up, gray stopped, red down or error, yellow stale, magenta version
559-// mismatch, blue no daemon.
556+// statusDot is the one-cell problem marker. It is drawn only when something
557+// is wrong, in a fixed xterm-256 color so it reads the same in any terminal
558+// and theme: red down or error, yellow stale, magenta version mismatch, blue
559+// no daemon. Healthy and stopped rows carry no dot: a stopped row is already
560+// muted, and the toast explains each failure in words.
560561 const statusDot = "●"
561562
562-// lineStatusColor is the xterm-256 index for a status, or -1 for none.
563+// Fixed xterm-256 problem colors, shared with client toast borders so one
564+// color always means one thing.
565+const (
566+ ColorProblemError = 167 // red
567+ ColorProblemWarn = 179 // yellow
568+)
569+
570+// lineStatusColor is the xterm-256 index for a problem status, or -1 for none.
563571 func lineStatusColor(status protocol.PickerLineStatus) int {
564572 switch status {
565- case protocol.PickerLineStatusUp:
566- return 71 // green
567- case protocol.PickerLineStatusStopped:
568- return 244 // gray
569573 case protocol.PickerLineStatusDown, protocol.PickerLineStatusError:
570- return 167 // red
574+ return ColorProblemError
571575 case protocol.PickerLineStatusStale:
572- return 179 // yellow
576+ return ColorProblemWarn
573577 case protocol.PickerLineStatusVersion:
574578 return 170 // magenta
575579 case protocol.PickerLineStatusNoDaemon:
@@ -579,6 +583,19 @@ func lineStatusColor(status protocol.PickerLineStatus) int {
579583 }
580584 }
581585
586+// drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps
587+// its own color even on the inverse selected row.
588+func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus) {
589+ if badge == "" {
590+ return
591+ }
592+ style := base
593+ style.Inverse = false
594+ style.Foreground = lineStatusColor(status)
595+ style.HasForegroundRGB = false
596+ ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style)
597+}
598+
582599 func lineStatusBadge(status protocol.PickerLineStatus) string {
583600 if lineStatusColor(status) < 0 {
584601 return ""
@@ -589,8 +606,8 @@ func lineStatusBadge(status protocol.PickerLineStatus) string {
589606 // renderList draws each visible row: a dimmed tree prefix, the name (bold on
590607 // unselected live session and host headers), the attention marker, then for
591608 // headers a right-aligned muted detail (tab count, "*" when attached) and a
592-// status dot in a fixed xterm-256 color. A tight width truncates the detail
593-// before the name.
609+// problem dot in a fixed xterm-256 color. Section headers carry their host's
610+// problem dot. A tight width truncates the detail before the name.
594611 func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {
595612 if m == nil || rect.Width <= 0 || rect.Height <= 0 {
596613 return
@@ -644,7 +661,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
644661 treeX = ui.DrawText(frame, rect.X, rect.Y+y, max(rect.X, clipX-2), r.tree, treeStyle)
645662 }
646663 badge := ""
647- if r.rendersAsHeader() {
664+ if r.rendersAsHeader() || r.section() {
648665 badge = lineStatusBadge(r.line.Status)
649666 }
650667 contentClipX := clipX
@@ -664,6 +681,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
664681 x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)
665682
666683 if r.section() {
684+ drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status)
667685 continue
668686 }
669687 if r.rendersAsHeader() {
@@ -679,15 +697,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
679697 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)
680698 drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions)
681699 }
682- if badge != "" {
683- badgeX := max(rect.X, clipX-badgeWidth)
684- badgeStyle := base
685- // The dot keeps its own color even on the inverse selected row.
686- badgeStyle.Inverse = false
687- badgeStyle.Foreground = lineStatusColor(r.line.Status)
688- badgeStyle.HasForegroundRGB = false
689- ui.DrawText(frame, badgeX, rect.Y+y, clipX, badge, badgeStyle)
690- }
700+ drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status)
691701 continue
692702 }
693703
@@ -553,23 +553,27 @@ func (s SortMode) Title() string {
553 return " Sessions · recent "553 return " Sessions · recent "
554 }554 }
555 555
556-// statusDot is the one-cell state marker. Every state uses the same dot and556+// statusDot is the one-cell problem marker. It is drawn only when something
557-// a fixed xterm-256 color, so it reads the same in any terminal and theme:557+// is wrong, in a fixed xterm-256 color so it reads the same in any terminal
558-// green up, gray stopped, red down or error, yellow stale, magenta version558+// and theme: red down or error, yellow stale, magenta version mismatch, blue
559-// mismatch, blue no daemon.559+// no daemon. Healthy and stopped rows carry no dot: a stopped row is already
560+// muted, and the toast explains each failure in words.
560 const statusDot = "●"561 const statusDot = "●"
561 562
562-// lineStatusColor is the xterm-256 index for a status, or -1 for none.563+// Fixed xterm-256 problem colors, shared with client toast borders so one
564+// color always means one thing.
565+const (
566+ ColorProblemError = 167 // red
567+ ColorProblemWarn = 179 // yellow
568+)
569+
570+// lineStatusColor is the xterm-256 index for a problem status, or -1 for none.
563 func lineStatusColor(status protocol.PickerLineStatus) int {571 func lineStatusColor(status protocol.PickerLineStatus) int {
564 switch status {572 switch status {
565- case protocol.PickerLineStatusUp:
566- return 71 // green
567- case protocol.PickerLineStatusStopped:
568- return 244 // gray
569 case protocol.PickerLineStatusDown, protocol.PickerLineStatusError:573 case protocol.PickerLineStatusDown, protocol.PickerLineStatusError:
570- return 167 // red574+ return ColorProblemError
571 case protocol.PickerLineStatusStale:575 case protocol.PickerLineStatusStale:
572- return 179 // yellow576+ return ColorProblemWarn
573 case protocol.PickerLineStatusVersion:577 case protocol.PickerLineStatusVersion:
574 return 170 // magenta578 return 170 // magenta
575 case protocol.PickerLineStatusNoDaemon:579 case protocol.PickerLineStatusNoDaemon:
@@ -579,6 +583,19 @@ func lineStatusColor(status protocol.PickerLineStatus) int {
579 }583 }
580 }584 }
581 585
586+// drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps
587+// its own color even on the inverse selected row.
588+func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus) {
589+ if badge == "" {
590+ return
591+ }
592+ style := base
593+ style.Inverse = false
594+ style.Foreground = lineStatusColor(status)
595+ style.HasForegroundRGB = false
596+ ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style)
597+}
598+
582 func lineStatusBadge(status protocol.PickerLineStatus) string {599 func lineStatusBadge(status protocol.PickerLineStatus) string {
583 if lineStatusColor(status) < 0 {600 if lineStatusColor(status) < 0 {
584 return ""601 return ""
@@ -589,8 +606,8 @@ func lineStatusBadge(status protocol.PickerLineStatus) string {
589 // renderList draws each visible row: a dimmed tree prefix, the name (bold on606 // renderList draws each visible row: a dimmed tree prefix, the name (bold on
590 // unselected live session and host headers), the attention marker, then for607 // unselected live session and host headers), the attention marker, then for
591 // headers a right-aligned muted detail (tab count, "*" when attached) and a608 // headers a right-aligned muted detail (tab count, "*" when attached) and a
592-// status dot in a fixed xterm-256 color. A tight width truncates the detail609+// problem dot in a fixed xterm-256 color. Section headers carry their host's
593-// before the name.610+// problem dot. A tight width truncates the detail before the name.
594 func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {611 func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles RenderStyles) {
595 if m == nil || rect.Width <= 0 || rect.Height <= 0 {612 if m == nil || rect.Width <= 0 || rect.Height <= 0 {
596 return613 return
@@ -644,7 +661,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
644 treeX = ui.DrawText(frame, rect.X, rect.Y+y, max(rect.X, clipX-2), r.tree, treeStyle)661 treeX = ui.DrawText(frame, rect.X, rect.Y+y, max(rect.X, clipX-2), r.tree, treeStyle)
645 }662 }
646 badge := ""663 badge := ""
647- if r.rendersAsHeader() {664+ if r.rendersAsHeader() || r.section() {
648 badge = lineStatusBadge(r.line.Status)665 badge = lineStatusBadge(r.line.Status)
649 }666 }
650 contentClipX := clipX667 contentClipX := clipX
@@ -664,6 +681,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
664 x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)681 x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)
665 682
666 if r.section() {683 if r.section() {
684+ drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status)
667 continue685 continue
668 }686 }
669 if r.rendersAsHeader() {687 if r.rendersAsHeader() {
@@ -679,15 +697,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
679 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)697 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)
680 drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions)698 drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions)
681 }699 }
682- if badge != "" {700+ drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status)
683- badgeX := max(rect.X, clipX-badgeWidth)
684- badgeStyle := base
685- // The dot keeps its own color even on the inverse selected row.
686- badgeStyle.Inverse = false
687- badgeStyle.Foreground = lineStatusColor(r.line.Status)
688- badgeStyle.HasForegroundRGB = false
689- ui.DrawText(frame, badgeX, rect.Y+y, clipX, badge, badgeStyle)
690- }
691 continue701 continue
692 }702 }
693 703
M internal/usecase/picker/model_test.go +12 -4
@@ -284,8 +284,13 @@ func TestChooseGeometryReservesTheStatusRow(t *testing.T) {
284284 require.Equal(t, domain.Rect{Y: 23, Width: 80, Height: 1}, geometry.Status)
285285 }
286286
287-func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) {
287+func TestRenderDrawsProblemDotsOnly(t *testing.T) {
288288 m := New([]protocol.PickerLine{
289+ {Kind: protocol.PickerLineSection, Label: "host-a", Status: protocol.PickerLineStatusDown},
290+ {
291+ Key: "a/broken", Kind: protocol.PickerLineSession, Label: "broken", Focusable: true, Actions: protocol.PickerCanNavigate,
292+ Status: protocol.PickerLineStatusError, Detail: "2",
293+ },
289294 {
290295 Key: "a/live", Kind: protocol.PickerLineSession, Label: "live", Focusable: true, Actions: protocol.PickerCanNavigate,
291296 Status: protocol.PickerLineStatusUp, Detail: "2",
@@ -299,14 +304,17 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) {
299304 frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{})
300305 require.Equal(t, 60, frame.Width)
301306 require.Equal(t, 8, frame.Height)
302- for row, want := range map[int]int{0: 71, 1: 244} {
307+ for row, want := range map[int]int{0: 167, 1: 167} {
303308 text := []rune(strings.TrimRight(rowText(frame.Row(row)), " "))
304- require.Equal(t, '●', text[len(text)-1])
309+ require.Equal(t, '●', text[len(text)-1], "row %d has a problem dot", row)
305310 dot := frame.At(len(text)-1, row).Style
306311 require.Equal(t, want, dot.Foreground, "row %d dot color", row)
307312 require.False(t, dot.Inverse, "row %d dot is never inverted, even when selected", row)
308313 }
309- require.True(t, frame.At(0, 0).Style.Inverse, "row 0 is the selected row")
314+ require.True(t, frame.At(0, 1).Style.Inverse, "the broken session is the selected row")
315+ for _, row := range []int{2, 3} {
316+ require.NotContains(t, rowText(frame.Row(row)), "●", "healthy and stopped rows carry no dot")
317+ }
310318 }
311319
312320 func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) {
@@ -284,8 +284,13 @@ func TestChooseGeometryReservesTheStatusRow(t *testing.T) {
284 require.Equal(t, domain.Rect{Y: 23, Width: 80, Height: 1}, geometry.Status)284 require.Equal(t, domain.Rect{Y: 23, Width: 80, Height: 1}, geometry.Status)
285 }285 }
286 286
287-func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) {287+func TestRenderDrawsProblemDotsOnly(t *testing.T) {
288 m := New([]protocol.PickerLine{288 m := New([]protocol.PickerLine{
289+ {Kind: protocol.PickerLineSection, Label: "host-a", Status: protocol.PickerLineStatusDown},
290+ {
291+ Key: "a/broken", Kind: protocol.PickerLineSession, Label: "broken", Focusable: true, Actions: protocol.PickerCanNavigate,
292+ Status: protocol.PickerLineStatusError, Detail: "2",
293+ },
289 {294 {
290 Key: "a/live", Kind: protocol.PickerLineSession, Label: "live", Focusable: true, Actions: protocol.PickerCanNavigate,295 Key: "a/live", Kind: protocol.PickerLineSession, Label: "live", Focusable: true, Actions: protocol.PickerCanNavigate,
291 Status: protocol.PickerLineStatusUp, Detail: "2",296 Status: protocol.PickerLineStatusUp, Detail: "2",
@@ -299,14 +304,17 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) {
299 frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{})304 frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{})
300 require.Equal(t, 60, frame.Width)305 require.Equal(t, 60, frame.Width)
301 require.Equal(t, 8, frame.Height)306 require.Equal(t, 8, frame.Height)
302- for row, want := range map[int]int{0: 71, 1: 244} {307+ for row, want := range map[int]int{0: 167, 1: 167} {
303 text := []rune(strings.TrimRight(rowText(frame.Row(row)), " "))308 text := []rune(strings.TrimRight(rowText(frame.Row(row)), " "))
304- require.Equal(t, '●', text[len(text)-1])309+ require.Equal(t, '●', text[len(text)-1], "row %d has a problem dot", row)
305 dot := frame.At(len(text)-1, row).Style310 dot := frame.At(len(text)-1, row).Style
306 require.Equal(t, want, dot.Foreground, "row %d dot color", row)311 require.Equal(t, want, dot.Foreground, "row %d dot color", row)
307 require.False(t, dot.Inverse, "row %d dot is never inverted, even when selected", row)312 require.False(t, dot.Inverse, "row %d dot is never inverted, even when selected", row)
308 }313 }
309- require.True(t, frame.At(0, 0).Style.Inverse, "row 0 is the selected row")314+ require.True(t, frame.At(0, 1).Style.Inverse, "the broken session is the selected row")
315+ for _, row := range []int{2, 3} {
316+ require.NotContains(t, rowText(frame.Row(row)), "●", "healthy and stopped rows carry no dot")
317+ }
310 }318 }
311 319
312 func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) {320 func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) {
M internal/usecase/ui/toast.go +5 -2
@@ -15,8 +15,11 @@ const (
1515
1616 // Toast describes a transient message rendered over a frame.
1717 type Toast struct {
18- ID string
19- Message string
18+ ID string
19+ Message string
20+ // Severity is carried for callers that style by it (client toast
21+ // borders); CompositeToasts does not read it.
22+ Severity domain.NoticeSeverity
2023 Anchor domain.Anchor
2124 Duration time.Duration
2225 DimBackground bool
@@ -15,8 +15,11 @@ const (
15 15
16 // Toast describes a transient message rendered over a frame.16 // Toast describes a transient message rendered over a frame.
17 type Toast struct {17 type Toast struct {
18- ID string18+ ID string
19- Message string19+ Message string
20+ // Severity is carried for callers that style by it (client toast
21+ // borders); CompositeToasts does not read it.
22+ Severity domain.NoticeSeverity
20 Anchor domain.Anchor23 Anchor domain.Anchor
21 Duration time.Duration24 Duration time.Duration
22 DimBackground bool25 DimBackground bool

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