feat(picker): right-align short session details and status words
- commit
- da33c7a8521c65976e9c95375d9fa14425db22f0
- author
- brice · · 2026-09-23 20:05 UTC
- parent
- 363b093
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +30 -31
M internal/usecase/client/picker_catalogue.go +9 -12
| @@ -4,7 +4,7 @@ import ( | |||
| 4 | 4 | "crypto/sha256" | |
| 5 | 5 | "encoding/base64" | |
| 6 | 6 | "encoding/binary" | |
| 7 | - | "fmt" | |
| 7 | + | "strconv" | |
| 8 | 8 | "strings" | |
| 9 | 9 | "sync" | |
| 10 | 10 | "time" | |
| @@ -906,9 +906,9 @@ func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bo | |||
| 906 | 906 | ||
| 907 | 907 | func pickerHostDetail(observation ports.BrokerDaemonObservation) string { | |
| 908 | 908 | if !observation.InventoryKnown { | |
| 909 | - | return "catalogue pending" | |
| 909 | + | return "…" | |
| 910 | 910 | } | |
| 911 | - | return fmt.Sprintf("%d sessions", len(observation.Sessions)) | |
| 911 | + | return strconv.Itoa(len(observation.Sessions)) | |
| 912 | 912 | } | |
| 913 | 913 | ||
| 914 | 914 | func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.PickerLineStatus { | |
| @@ -925,17 +925,14 @@ func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.Picker | |||
| 925 | 925 | } | |
| 926 | 926 | ||
| 927 | 927 | func pickerSessionDetail(session catalogue.RemoteCatalogSession) string { | |
| 928 | - | parts := make([]string, 0, 2) | |
| 929 | - | if session.Attached { | |
| 930 | - | parts = append(parts, "attached") | |
| 928 | + | count := "" | |
| 929 | + | if n := len(session.Tabs); n > 0 { | |
| 930 | + | count = strconv.Itoa(n) | |
| 931 | 931 | } | |
| 932 | - | switch count := len(session.Tabs); { | |
| 933 | - | case count == 1: | |
| 934 | - | parts = append(parts, "1 tab") | |
| 935 | - | case count > 1: | |
| 936 | - | parts = append(parts, fmt.Sprintf("%d tabs", count)) | |
| 932 | + | if session.Attached { | |
| 933 | + | return strings.TrimSpace("● " + count) | |
| 937 | 934 | } | |
| 938 | - | return strings.Join(parts, " · ") | |
| 935 | + | return count | |
| 939 | 936 | } | |
| 940 | 937 | ||
| 941 | 938 | // pickerCatalogueCursor hints the first actionable row, then the first | |
| @@ -4,7 +4,7 @@ import ( | |||||
| 4 | "crypto/sha256" | 4 | "crypto/sha256" | ||
| 5 | "encoding/base64" | 5 | "encoding/base64" | ||
| 6 | "encoding/binary" | 6 | "encoding/binary" | ||
| 7 | - | "fmt" | 7 | + | "strconv" |
| 8 | "strings" | 8 | "strings" | ||
| 9 | "sync" | 9 | "sync" | ||
| 10 | "time" | 10 | "time" | ||
| @@ -906,9 +906,9 @@ func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bo | |||||
| 906 | 906 | ||||
| 907 | func pickerHostDetail(observation ports.BrokerDaemonObservation) string { | 907 | func pickerHostDetail(observation ports.BrokerDaemonObservation) string { | ||
| 908 | if !observation.InventoryKnown { | 908 | if !observation.InventoryKnown { | ||
| 909 | - | return "catalogue pending" | 909 | + | return "…" |
| 910 | } | 910 | } | ||
| 911 | - | return fmt.Sprintf("%d sessions", len(observation.Sessions)) | 911 | + | return strconv.Itoa(len(observation.Sessions)) |
| 912 | } | 912 | } | ||
| 913 | 913 | ||||
| 914 | func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.PickerLineStatus { | 914 | func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.PickerLineStatus { | ||
| @@ -925,17 +925,14 @@ func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.Picker | |||||
| 925 | } | 925 | } | ||
| 926 | 926 | ||||
| 927 | func pickerSessionDetail(session catalogue.RemoteCatalogSession) string { | 927 | func pickerSessionDetail(session catalogue.RemoteCatalogSession) string { | ||
| 928 | - | parts := make([]string, 0, 2) | 928 | + | count := "" |
| 929 | - | if session.Attached { | 929 | + | if n := len(session.Tabs); n > 0 { |
| 930 | - | parts = append(parts, "attached") | 930 | + | count = strconv.Itoa(n) |
| 931 | } | 931 | } | ||
| 932 | - | switch count := len(session.Tabs); { | 932 | + | if session.Attached { |
| 933 | - | case count == 1: | 933 | + | return strings.TrimSpace("● " + count) |
| 934 | - | parts = append(parts, "1 tab") | |||
| 935 | - | case count > 1: | |||
| 936 | - | parts = append(parts, fmt.Sprintf("%d tabs", count)) | |||
| 937 | } | 934 | } | ||
| 938 | - | return strings.Join(parts, " · ") | 935 | + | return count |
| 939 | } | 936 | } | ||
| 940 | 937 | ||||
| 941 | // pickerCatalogueCursor hints the first actionable row, then the first | 938 | // pickerCatalogueCursor hints the first actionable row, then the first | ||
M internal/usecase/client/picker_controller.go +2 -2
| @@ -102,8 +102,8 @@ type pickerController struct { | |||
| 102 | 102 | // reopen for the lifetime of the client process. | |
| 103 | 103 | sort pickerusecase.SortMode | |
| 104 | 104 | ownsInput bool | |
| 105 | - | opsReady chan struct{} | |
| 106 | - | lastOp pickerOp | |
| 105 | + | opsReady chan struct{} | |
| 106 | + | lastOp pickerOp | |
| 107 | 107 | // lastCommitKey is the catalogue key captured with the pending commit | |
| 108 | 108 | // decision, so TakeOp can hand a driver the exact committed row. | |
| 109 | 109 | lastCommitKey string | |
| @@ -102,8 +102,8 @@ type pickerController struct { | |||||
| 102 | // reopen for the lifetime of the client process. | 102 | // reopen for the lifetime of the client process. | ||
| 103 | sort pickerusecase.SortMode | 103 | sort pickerusecase.SortMode | ||
| 104 | ownsInput bool | 104 | ownsInput bool | ||
| 105 | - | opsReady chan struct{} | 105 | + | opsReady chan struct{} |
| 106 | - | lastOp pickerOp | 106 | + | lastOp pickerOp |
| 107 | // lastCommitKey is the catalogue key captured with the pending commit | 107 | // lastCommitKey is the catalogue key captured with the pending commit | ||
| 108 | // decision, so TakeOp can hand a driver the exact committed row. | 108 | // decision, so TakeOp can hand a driver the exact committed row. | ||
| 109 | lastCommitKey string | 109 | lastCommitKey string | ||
M internal/usecase/picker/model.go +15 -13
| @@ -542,19 +542,19 @@ func (s SortMode) Title() string { | |||
| 542 | 542 | func lineStatusBadge(status protocol.PickerLineStatus) string { | |
| 543 | 543 | switch status { | |
| 544 | 544 | case protocol.PickerLineStatusUp: | |
| 545 | - | return "[up]" | |
| 545 | + | return "up" | |
| 546 | 546 | case protocol.PickerLineStatusStopped: | |
| 547 | - | return "[stopped]" | |
| 547 | + | return "down" | |
| 548 | 548 | case protocol.PickerLineStatusDown: | |
| 549 | - | return "[down]" | |
| 549 | + | return "off" | |
| 550 | 550 | case protocol.PickerLineStatusStale: | |
| 551 | - | return "[stale]" | |
| 551 | + | return "stale" | |
| 552 | 552 | case protocol.PickerLineStatusVersion: | |
| 553 | - | return "[version]" | |
| 553 | + | return "ver" | |
| 554 | 554 | case protocol.PickerLineStatusError: | |
| 555 | - | return "[error]" | |
| 555 | + | return "err" | |
| 556 | 556 | case protocol.PickerLineStatusNoDaemon: | |
| 557 | - | return "[no daemon]" | |
| 557 | + | return "none" | |
| 558 | 558 | default: | |
| 559 | 559 | return "" | |
| 560 | 560 | } | |
| @@ -635,15 +635,17 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 635 | 635 | // A session header rings when any of its tabs does. | |
| 636 | 636 | x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " "+string(ui.AttentionGlyph), base) | |
| 637 | 637 | } | |
| 638 | - | if r.line.Detail != "" && x < contentClipX { | |
| 639 | - | x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " ", detailStyle) | |
| 638 | + | // The short detail (tab count, attached marker) is right-aligned | |
| 639 | + | // before the badge, muted but not dimmed so it stays readable. | |
| 640 | + | if r.line.Detail != "" && x < contentClipX-1 { | |
| 641 | + | detail := ui.TruncateText(r.line.Detail, contentClipX-x-1) | |
| 642 | + | detailX := max(x+1, contentClipX-textCellWidth(detail)) | |
| 643 | + | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | |
| 644 | + | drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | |
| 640 | 645 | } | |
| 641 | - | detail := ui.TruncateText(r.line.Detail, contentClipX-x) | |
| 642 | - | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | |
| 643 | - | drawMatchedText(frame, x, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | |
| 644 | 646 | if badge != "" { | |
| 645 | 647 | badgeX := max(rect.X, clipX-badgeWidth) | |
| 646 | - | ui.DrawText(frame, badgeX, rect.Y+y, clipX, badge, base) | |
| 648 | + | ui.DrawText(frame, badgeX, rect.Y+y, clipX, badge, detailStyle) | |
| 647 | 649 | } | |
| 648 | 650 | continue | |
| 649 | 651 | } | |
| @@ -542,19 +542,19 @@ func (s SortMode) Title() string { | |||||
| 542 | func lineStatusBadge(status protocol.PickerLineStatus) string { | 542 | func lineStatusBadge(status protocol.PickerLineStatus) string { | ||
| 543 | switch status { | 543 | switch status { | ||
| 544 | case protocol.PickerLineStatusUp: | 544 | case protocol.PickerLineStatusUp: | ||
| 545 | - | return "[up]" | 545 | + | return "up" |
| 546 | case protocol.PickerLineStatusStopped: | 546 | case protocol.PickerLineStatusStopped: | ||
| 547 | - | return "[stopped]" | 547 | + | return "down" |
| 548 | case protocol.PickerLineStatusDown: | 548 | case protocol.PickerLineStatusDown: | ||
| 549 | - | return "[down]" | 549 | + | return "off" |
| 550 | case protocol.PickerLineStatusStale: | 550 | case protocol.PickerLineStatusStale: | ||
| 551 | - | return "[stale]" | 551 | + | return "stale" |
| 552 | case protocol.PickerLineStatusVersion: | 552 | case protocol.PickerLineStatusVersion: | ||
| 553 | - | return "[version]" | 553 | + | return "ver" |
| 554 | case protocol.PickerLineStatusError: | 554 | case protocol.PickerLineStatusError: | ||
| 555 | - | return "[error]" | 555 | + | return "err" |
| 556 | case protocol.PickerLineStatusNoDaemon: | 556 | case protocol.PickerLineStatusNoDaemon: | ||
| 557 | - | return "[no daemon]" | 557 | + | return "none" |
| 558 | default: | 558 | default: | ||
| 559 | return "" | 559 | return "" | ||
| 560 | } | 560 | } | ||
| @@ -635,15 +635,17 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 635 | // A session header rings when any of its tabs does. | 635 | // A session header rings when any of its tabs does. | ||
| 636 | x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " "+string(ui.AttentionGlyph), base) | 636 | x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " "+string(ui.AttentionGlyph), base) | ||
| 637 | } | 637 | } | ||
| 638 | - | if r.line.Detail != "" && x < contentClipX { | 638 | + | // The short detail (tab count, attached marker) is right-aligned |
| 639 | - | x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " ", detailStyle) | 639 | + | // before the badge, muted but not dimmed so it stays readable. |
| 640 | + | if r.line.Detail != "" && x < contentClipX-1 { | |||
| 641 | + | detail := ui.TruncateText(r.line.Detail, contentClipX-x-1) | |||
| 642 | + | detailX := max(x+1, contentClipX-textCellWidth(detail)) | |||
| 643 | + | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | |||
| 644 | + | drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | |||
| 640 | } | 645 | } | ||
| 641 | - | detail := ui.TruncateText(r.line.Detail, contentClipX-x) | |||
| 642 | - | detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail) | |||
| 643 | - | drawMatchedText(frame, x, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions) | |||
| 644 | if badge != "" { | 646 | if badge != "" { | ||
| 645 | badgeX := max(rect.X, clipX-badgeWidth) | 647 | badgeX := max(rect.X, clipX-badgeWidth) | ||
| 646 | - | ui.DrawText(frame, badgeX, rect.Y+y, clipX, badge, base) | 648 | + | ui.DrawText(frame, badgeX, rect.Y+y, clipX, badge, detailStyle) |
| 647 | } | 649 | } | ||
| 648 | continue | 650 | continue | ||
| 649 | } | 651 | } | ||
M internal/usecase/picker/model_test.go +4 -4
| @@ -288,7 +288,7 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) { | |||
| 288 | 288 | m := New([]protocol.PickerLine{ | |
| 289 | 289 | { | |
| 290 | 290 | Key: "a/live", Kind: protocol.PickerLineSession, Label: "live", Focusable: true, Actions: protocol.PickerCanNavigate, | |
| 291 | - | Status: protocol.PickerLineStatusUp, Detail: "up", | |
| 291 | + | Status: protocol.PickerLineStatusUp, Detail: "2", | |
| 292 | 292 | }, | |
| 293 | 293 | { | |
| 294 | 294 | Key: "b/old", Kind: protocol.PickerLineSession, Label: "old", Stopped: true, | |
| @@ -299,8 +299,8 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) { | |||
| 299 | 299 | frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{}) | |
| 300 | 300 | require.Equal(t, 60, frame.Width) | |
| 301 | 301 | require.Equal(t, 8, frame.Height) | |
| 302 | - | require.NotContains(t, rowText(frame.Row(0)), "[up]") | |
| 303 | - | require.Contains(t, rowText(frame.Row(1)), "[stopped]") | |
| 302 | + | require.NotRegexp(t, `\bup\s*$`, strings.TrimRight(rowText(frame.Row(0)), " "), "live sessions carry no badge") | |
| 303 | + | require.Regexp(t, `down\s*$`, strings.TrimRight(rowText(frame.Row(1)), " ")) | |
| 304 | 304 | } | |
| 305 | 305 | ||
| 306 | 306 | func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) { | |
| @@ -310,7 +310,7 @@ func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) { | |||
| 310 | 310 | Dim: true, Focusable: true, Actions: protocol.PickerCanNavigate, | |
| 311 | 311 | }}, Config{Intent: protocol.PickerIntentNavigation}) | |
| 312 | 312 | frame := m.Render(domain.Size{Cols: 80, Rows: 8}, Preview{}) | |
| 313 | - | require.Contains(t, rowText(frame.Row(0)), "[no daemon]") | |
| 313 | + | require.Contains(t, rowText(frame.Row(0)), "none") | |
| 314 | 314 | require.Contains(t, rowText(frame.Row(7)), "no daemon") | |
| 315 | 315 | require.Contains(t, rowText(frame.Row(7)), "Enter to create a session") | |
| 316 | 316 | require.NotContains(t, rowText(frame.Row(7)), "Enter unavailable") | |
| @@ -288,7 +288,7 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) { | |||||
| 288 | m := New([]protocol.PickerLine{ | 288 | m := New([]protocol.PickerLine{ | ||
| 289 | { | 289 | { | ||
| 290 | Key: "a/live", Kind: protocol.PickerLineSession, Label: "live", Focusable: true, Actions: protocol.PickerCanNavigate, | 290 | Key: "a/live", Kind: protocol.PickerLineSession, Label: "live", Focusable: true, Actions: protocol.PickerCanNavigate, | ||
| 291 | - | Status: protocol.PickerLineStatusUp, Detail: "up", | 291 | + | Status: protocol.PickerLineStatusUp, Detail: "2", |
| 292 | }, | 292 | }, | ||
| 293 | { | 293 | { | ||
| 294 | Key: "b/old", Kind: protocol.PickerLineSession, Label: "old", Stopped: true, | 294 | Key: "b/old", Kind: protocol.PickerLineSession, Label: "old", Stopped: true, | ||
| @@ -299,8 +299,8 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) { | |||||
| 299 | frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{}) | 299 | frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{}) | ||
| 300 | require.Equal(t, 60, frame.Width) | 300 | require.Equal(t, 60, frame.Width) | ||
| 301 | require.Equal(t, 8, frame.Height) | 301 | require.Equal(t, 8, frame.Height) | ||
| 302 | - | require.NotContains(t, rowText(frame.Row(0)), "[up]") | 302 | + | require.NotRegexp(t, `\bup\s*$`, strings.TrimRight(rowText(frame.Row(0)), " "), "live sessions carry no badge") |
| 303 | - | require.Contains(t, rowText(frame.Row(1)), "[stopped]") | 303 | + | require.Regexp(t, `down\s*$`, strings.TrimRight(rowText(frame.Row(1)), " ")) |
| 304 | } | 304 | } | ||
| 305 | 305 | ||||
| 306 | func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) { | 306 | func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) { | ||
| @@ -310,7 +310,7 @@ func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) { | |||||
| 310 | Dim: true, Focusable: true, Actions: protocol.PickerCanNavigate, | 310 | Dim: true, Focusable: true, Actions: protocol.PickerCanNavigate, | ||
| 311 | }}, Config{Intent: protocol.PickerIntentNavigation}) | 311 | }}, Config{Intent: protocol.PickerIntentNavigation}) | ||
| 312 | frame := m.Render(domain.Size{Cols: 80, Rows: 8}, Preview{}) | 312 | frame := m.Render(domain.Size{Cols: 80, Rows: 8}, Preview{}) | ||
| 313 | - | require.Contains(t, rowText(frame.Row(0)), "[no daemon]") | 313 | + | require.Contains(t, rowText(frame.Row(0)), "none") |
| 314 | require.Contains(t, rowText(frame.Row(7)), "no daemon") | 314 | require.Contains(t, rowText(frame.Row(7)), "no daemon") | ||
| 315 | require.Contains(t, rowText(frame.Row(7)), "Enter to create a session") | 315 | require.Contains(t, rowText(frame.Row(7)), "Enter to create a session") | ||
| 316 | require.NotContains(t, rowText(frame.Row(7)), "Enter unavailable") | 316 | require.NotContains(t, rowText(frame.Row(7)), "Enter unavailable") | ||