detached f06fef6

feat(picker): show session state as a 256-color dot

commit
f06fef660bc17b39a71d56b9f51d2c5d3a064cad
author
brice · · 2026-09-23 20:10 UTC
parent
da33c7a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
930930 count = strconv.Itoa(n)
931931 }
932932 if session.Attached {
933- return strings.TrimSpace("● " + count)
933+ // "*" marks the attached session; ● is reserved for the status dot.
934+ return strings.TrimSpace("* " + count)
934935 }
935936 return count
936937 }
@@ -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 count936 return count
936 }937 }
M internal/usecase/picker/model.go +27 -12
@@ -539,25 +539,37 @@ func (s SortMode) Title() string {
539539 return " Sessions · recent "
540540 }
541541
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 {
543550 switch status {
544551 case protocol.PickerLineStatusUp:
545- return "up"
552+ return 71 // green
546553 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
550557 case protocol.PickerLineStatusStale:
551- return "stale"
558+ return 179 // yellow
552559 case protocol.PickerLineStatusVersion:
553- return "ver"
554- case protocol.PickerLineStatusError:
555- return "err"
560+ return 170 // magenta
556561 case protocol.PickerLineStatusNoDaemon:
557- return "none"
562+ return 67 // blue
558563 default:
564+ return -1
565+ }
566+}
567+
568+func lineStatusBadge(status protocol.PickerLineStatus) string {
569+ if lineStatusColor(status) < 0 {
559570 return ""
560571 }
572+ return statusDot
561573 }
562574
563575 // 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
608620 treeX = ui.DrawText(frame, rect.X, rect.Y+y, clipX, r.tree, treeStyle)
609621 }
610622 badge := ""
611- if r.rendersAsHeader() && !(r.kind() == protocol.PickerLineSession && r.line.Status == protocol.PickerLineStatusUp) {
623+ if r.rendersAsHeader() {
612624 badge = lineStatusBadge(r.line.Status)
613625 }
614626 contentClipX := clipX
@@ -645,7 +657,10 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
645657 }
646658 if badge != "" {
647659 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)
649664 }
650665 continue
651666 }
@@ -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 segment575 // 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 := clipX626 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 continue665 continue
651 }666 }
M internal/usecase/picker/model_test.go +6 -3
@@ -299,8 +299,11 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) {
299299 frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{})
300300 require.Equal(t, 60, frame.Width)
301301 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+ }
304307 }
305308
306309 func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) {
@@ -310,7 +313,7 @@ func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) {
310313 Dim: true, Focusable: true, Actions: protocol.PickerCanNavigate,
311314 }}, Config{Intent: protocol.PickerIntentNavigation})
312315 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)), "●")
314317 require.Contains(t, rowText(frame.Row(7)), "no daemon")
315318 require.Contains(t, rowText(frame.Row(7)), "Enter to create a session")
316319 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")

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