feat(picker): show session state as a 256-color dot
- commit
- f06fef660bc17b39a71d56b9f51d2c5d3a064cad
- author
- brice · · 2026-09-23 20:10 UTC
- parent
- da33c7a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +35 -16
M internal/usecase/client/picker_catalogue.go +2 -1
| @@ -930,7 +930,8 @@ func pickerSessionDetail(session catalogue.RemoteCatalogSession) string { | |||
| 930 | 930 | count = strconv.Itoa(n) | |
| 931 | 931 | } | |
| 932 | 932 | if session.Attached { | |
| 933 | - | return strings.TrimSpace("● " + count) | |
| 933 | + | // "*" marks the attached session; ● is reserved for the status dot. | |
| 934 | + | return strings.TrimSpace("* " + count) | |
| 934 | 935 | } | |
| 935 | 936 | return count | |
| 936 | 937 | } | |
| @@ -930,7 +930,8 @@ func pickerSessionDetail(session catalogue.RemoteCatalogSession) string { | |||||
| 930 | count = strconv.Itoa(n) | 930 | count = strconv.Itoa(n) | ||
| 931 | } | 931 | } | ||
| 932 | if session.Attached { | 932 | if session.Attached { | ||
| 933 | - | return strings.TrimSpace("● " + count) | 933 | + | // "*" marks the attached session; ● is reserved for the status dot. |
| 934 | + | return strings.TrimSpace("* " + count) | |||
| 934 | } | 935 | } | ||
| 935 | return count | 936 | return count | ||
| 936 | } | 937 | } | ||
M internal/usecase/picker/model.go +27 -12
| @@ -539,25 +539,37 @@ func (s SortMode) Title() string { | |||
| 539 | 539 | return " Sessions · recent " | |
| 540 | 540 | } | |
| 541 | 541 | ||
| 542 | - | func lineStatusBadge(status protocol.PickerLineStatus) string { | |
| 542 | + | // statusDot is the one-cell state marker. Every state uses the same dot and | |
| 543 | + | // a fixed xterm-256 color, so it reads the same in any terminal and theme: | |
| 544 | + | // green up, gray stopped, red down or error, yellow stale, magenta version | |
| 545 | + | // mismatch, blue no daemon. | |
| 546 | + | const statusDot = "●" | |
| 547 | + | ||
| 548 | + | // lineStatusColor is the xterm-256 index for a status, or -1 for none. | |
| 549 | + | func lineStatusColor(status protocol.PickerLineStatus) int { | |
| 543 | 550 | switch status { | |
| 544 | 551 | case protocol.PickerLineStatusUp: | |
| 545 | - | return "up" | |
| 552 | + | return 71 // green | |
| 546 | 553 | case protocol.PickerLineStatusStopped: | |
| 547 | - | return "down" | |
| 548 | - | case protocol.PickerLineStatusDown: | |
| 549 | - | return "off" | |
| 554 | + | return 244 // gray | |
| 555 | + | case protocol.PickerLineStatusDown, protocol.PickerLineStatusError: | |
| 556 | + | return 167 // red | |
| 550 | 557 | case protocol.PickerLineStatusStale: | |
| 551 | - | return "stale" | |
| 558 | + | return 179 // yellow | |
| 552 | 559 | case protocol.PickerLineStatusVersion: | |
| 553 | - | return "ver" | |
| 554 | - | case protocol.PickerLineStatusError: | |
| 555 | - | return "err" | |
| 560 | + | return 170 // magenta | |
| 556 | 561 | case protocol.PickerLineStatusNoDaemon: | |
| 557 | - | return "none" | |
| 562 | + | return 67 // blue | |
| 558 | 563 | default: | |
| 564 | + | return -1 | |
| 565 | + | } | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | func lineStatusBadge(status protocol.PickerLineStatus) string { | |
| 569 | + | if lineStatusColor(status) < 0 { | |
| 559 | 570 | return "" | |
| 560 | 571 | } | |
| 572 | + | return statusDot | |
| 561 | 573 | } | |
| 562 | 574 | ||
| 563 | 575 | // renderList draws each visible row as up to three segments: a name segment | |
| @@ -608,7 +620,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 608 | 620 | treeX = ui.DrawText(frame, rect.X, rect.Y+y, clipX, r.tree, treeStyle) | |
| 609 | 621 | } | |
| 610 | 622 | badge := "" | |
| 611 | - | if r.rendersAsHeader() && !(r.kind() == protocol.PickerLineSession && r.line.Status == protocol.PickerLineStatusUp) { | |
| 623 | + | if r.rendersAsHeader() { | |
| 612 | 624 | badge = lineStatusBadge(r.line.Status) | |
| 613 | 625 | } | |
| 614 | 626 | contentClipX := clipX | |
| @@ -645,7 +657,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||
| 645 | 657 | } | |
| 646 | 658 | if badge != "" { | |
| 647 | 659 | badgeX := max(rect.X, clipX-badgeWidth) | |
| 648 | - | ui.DrawText(frame, badgeX, rect.Y+y, clipX, badge, detailStyle) | |
| 660 | + | badgeStyle := base | |
| 661 | + | badgeStyle.Foreground = lineStatusColor(r.line.Status) | |
| 662 | + | badgeStyle.HasForegroundRGB = false | |
| 663 | + | ui.DrawText(frame, badgeX, rect.Y+y, clipX, badge, badgeStyle) | |
| 649 | 664 | } | |
| 650 | 665 | continue | |
| 651 | 666 | } | |
| @@ -539,25 +539,37 @@ func (s SortMode) Title() string { | |||||
| 539 | return " Sessions · recent " | 539 | return " Sessions · recent " | ||
| 540 | } | 540 | } | ||
| 541 | 541 | ||||
| 542 | - | func lineStatusBadge(status protocol.PickerLineStatus) string { | 542 | + | // statusDot is the one-cell state marker. Every state uses the same dot and |
| 543 | + | // a fixed xterm-256 color, so it reads the same in any terminal and theme: | |||
| 544 | + | // green up, gray stopped, red down or error, yellow stale, magenta version | |||
| 545 | + | // mismatch, blue no daemon. | |||
| 546 | + | const statusDot = "●" | |||
| 547 | + | ||||
| 548 | + | // lineStatusColor is the xterm-256 index for a status, or -1 for none. | |||
| 549 | + | func lineStatusColor(status protocol.PickerLineStatus) int { | |||
| 543 | switch status { | 550 | switch status { | ||
| 544 | case protocol.PickerLineStatusUp: | 551 | case protocol.PickerLineStatusUp: | ||
| 545 | - | return "up" | 552 | + | return 71 // green |
| 546 | case protocol.PickerLineStatusStopped: | 553 | case protocol.PickerLineStatusStopped: | ||
| 547 | - | return "down" | 554 | + | return 244 // gray |
| 548 | - | case protocol.PickerLineStatusDown: | 555 | + | case protocol.PickerLineStatusDown, protocol.PickerLineStatusError: |
| 549 | - | return "off" | 556 | + | return 167 // red |
| 550 | case protocol.PickerLineStatusStale: | 557 | case protocol.PickerLineStatusStale: | ||
| 551 | - | return "stale" | 558 | + | return 179 // yellow |
| 552 | case protocol.PickerLineStatusVersion: | 559 | case protocol.PickerLineStatusVersion: | ||
| 553 | - | return "ver" | 560 | + | return 170 // magenta |
| 554 | - | case protocol.PickerLineStatusError: | |||
| 555 | - | return "err" | |||
| 556 | case protocol.PickerLineStatusNoDaemon: | 561 | case protocol.PickerLineStatusNoDaemon: | ||
| 557 | - | return "none" | 562 | + | return 67 // blue |
| 558 | default: | 563 | default: | ||
| 564 | + | return -1 | |||
| 565 | + | } | |||
| 566 | + | } | |||
| 567 | + | ||||
| 568 | + | func lineStatusBadge(status protocol.PickerLineStatus) string { | |||
| 569 | + | if lineStatusColor(status) < 0 { | |||
| 559 | return "" | 570 | return "" | ||
| 560 | } | 571 | } | ||
| 572 | + | return statusDot | |||
| 561 | } | 573 | } | ||
| 562 | 574 | ||||
| 563 | // renderList draws each visible row as up to three segments: a name segment | 575 | // renderList draws each visible row as up to three segments: a name segment | ||
| @@ -608,7 +620,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 608 | treeX = ui.DrawText(frame, rect.X, rect.Y+y, clipX, r.tree, treeStyle) | 620 | treeX = ui.DrawText(frame, rect.X, rect.Y+y, clipX, r.tree, treeStyle) | ||
| 609 | } | 621 | } | ||
| 610 | badge := "" | 622 | badge := "" | ||
| 611 | - | if r.rendersAsHeader() && !(r.kind() == protocol.PickerLineSession && r.line.Status == protocol.PickerLineStatusUp) { | 623 | + | if r.rendersAsHeader() { |
| 612 | badge = lineStatusBadge(r.line.Status) | 624 | badge = lineStatusBadge(r.line.Status) | ||
| 613 | } | 625 | } | ||
| 614 | contentClipX := clipX | 626 | contentClipX := clipX | ||
| @@ -645,7 +657,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render | |||||
| 645 | } | 657 | } | ||
| 646 | if badge != "" { | 658 | if badge != "" { | ||
| 647 | badgeX := max(rect.X, clipX-badgeWidth) | 659 | badgeX := max(rect.X, clipX-badgeWidth) | ||
| 648 | - | ui.DrawText(frame, badgeX, rect.Y+y, clipX, badge, detailStyle) | 660 | + | badgeStyle := base |
| 661 | + | badgeStyle.Foreground = lineStatusColor(r.line.Status) | |||
| 662 | + | badgeStyle.HasForegroundRGB = false | |||
| 663 | + | ui.DrawText(frame, badgeX, rect.Y+y, clipX, badge, badgeStyle) | |||
| 649 | } | 664 | } | ||
| 650 | continue | 665 | continue | ||
| 651 | } | 666 | } | ||
M internal/usecase/picker/model_test.go +6 -3
| @@ -299,8 +299,11 @@ 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.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)), " ")) | |
| 302 | + | for row, want := range map[int]int{0: 71, 1: 244} { | |
| 303 | + | text := []rune(strings.TrimRight(rowText(frame.Row(row)), " ")) | |
| 304 | + | require.Equal(t, '●', text[len(text)-1]) | |
| 305 | + | require.Equal(t, want, frame.At(len(text)-1, row).Style.Foreground, "row %d dot color", row) | |
| 306 | + | } | |
| 304 | 307 | } | |
| 305 | 308 | ||
| 306 | 309 | func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) { | |
| @@ -310,7 +313,7 @@ func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) { | |||
| 310 | 313 | Dim: true, Focusable: true, Actions: protocol.PickerCanNavigate, | |
| 311 | 314 | }}, Config{Intent: protocol.PickerIntentNavigation}) | |
| 312 | 315 | frame := m.Render(domain.Size{Cols: 80, Rows: 8}, Preview{}) | |
| 313 | - | require.Contains(t, rowText(frame.Row(0)), "none") | |
| 316 | + | require.Contains(t, rowText(frame.Row(0)), "●") | |
| 314 | 317 | require.Contains(t, rowText(frame.Row(7)), "no daemon") | |
| 315 | 318 | require.Contains(t, rowText(frame.Row(7)), "Enter to create a session") | |
| 316 | 319 | require.NotContains(t, rowText(frame.Row(7)), "Enter unavailable") | |
| @@ -299,8 +299,11 @@ 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.NotRegexp(t, `\bup\s*$`, strings.TrimRight(rowText(frame.Row(0)), " "), "live sessions carry no badge") | 302 | + | for row, want := range map[int]int{0: 71, 1: 244} { |
| 303 | - | require.Regexp(t, `down\s*$`, strings.TrimRight(rowText(frame.Row(1)), " ")) | 303 | + | text := []rune(strings.TrimRight(rowText(frame.Row(row)), " ")) |
| 304 | + | require.Equal(t, '●', text[len(text)-1]) | |||
| 305 | + | require.Equal(t, want, frame.At(len(text)-1, row).Style.Foreground, "row %d dot color", row) | |||
| 306 | + | } | |||
| 304 | } | 307 | } | ||
| 305 | 308 | ||||
| 306 | func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) { | 309 | func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) { | ||
| @@ -310,7 +313,7 @@ func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) { | |||||
| 310 | Dim: true, Focusable: true, Actions: protocol.PickerCanNavigate, | 313 | Dim: true, Focusable: true, Actions: protocol.PickerCanNavigate, | ||
| 311 | }}, Config{Intent: protocol.PickerIntentNavigation}) | 314 | }}, Config{Intent: protocol.PickerIntentNavigation}) | ||
| 312 | frame := m.Render(domain.Size{Cols: 80, Rows: 8}, Preview{}) | 315 | frame := m.Render(domain.Size{Cols: 80, Rows: 8}, Preview{}) | ||
| 313 | - | require.Contains(t, rowText(frame.Row(0)), "none") | 316 | + | require.Contains(t, rowText(frame.Row(0)), "●") |
| 314 | require.Contains(t, rowText(frame.Row(7)), "no daemon") | 317 | require.Contains(t, rowText(frame.Row(7)), "no daemon") | ||
| 315 | require.Contains(t, rowText(frame.Row(7)), "Enter to create a session") | 318 | require.Contains(t, rowText(frame.Row(7)), "Enter to create a session") | ||
| 316 | require.NotContains(t, rowText(frame.Row(7)), "Enter unavailable") | 319 | require.NotContains(t, rowText(frame.Row(7)), "Enter unavailable") | ||