detached da33c7a

feat(picker): right-align short session details and status words

commit
da33c7a8521c65976e9c95375d9fa14425db22f0
author
brice · · 2026-09-23 20:05 UTC
parent
363b093
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 (
44 "crypto/sha256"
55 "encoding/base64"
66 "encoding/binary"
7- "fmt"
7+ "strconv"
88 "strings"
99 "sync"
1010 "time"
@@ -906,9 +906,9 @@ func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bo
906906
907907 func pickerHostDetail(observation ports.BrokerDaemonObservation) string {
908908 if !observation.InventoryKnown {
909- return "catalogue pending"
909+ return "…"
910910 }
911- return fmt.Sprintf("%d sessions", len(observation.Sessions))
911+ return strconv.Itoa(len(observation.Sessions))
912912 }
913913
914914 func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.PickerLineStatus {
@@ -925,17 +925,14 @@ func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.Picker
925925 }
926926
927927 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)
931931 }
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)
937934 }
938- return strings.Join(parts, " · ")
935+ return count
939936 }
940937
941938 // 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 first938 // 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 {
102102 // reopen for the lifetime of the client process.
103103 sort pickerusecase.SortMode
104104 ownsInput bool
105- opsReady chan struct{}
106- lastOp pickerOp
105+ opsReady chan struct{}
106+ lastOp pickerOp
107107 // lastCommitKey is the catalogue key captured with the pending commit
108108 // decision, so TakeOp can hand a driver the exact committed row.
109109 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.SortMode103 sort pickerusecase.SortMode
104 ownsInput bool104 ownsInput bool
105- opsReady chan struct{}105+ opsReady chan struct{}
106- lastOp pickerOp106+ lastOp pickerOp
107 // lastCommitKey is the catalogue key captured with the pending commit107 // 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 string109 lastCommitKey string
M internal/usecase/picker/model.go +15 -13
@@ -542,19 +542,19 @@ func (s SortMode) Title() string {
542542 func lineStatusBadge(status protocol.PickerLineStatus) string {
543543 switch status {
544544 case protocol.PickerLineStatusUp:
545- return "[up]"
545+ return "up"
546546 case protocol.PickerLineStatusStopped:
547- return "[stopped]"
547+ return "down"
548548 case protocol.PickerLineStatusDown:
549- return "[down]"
549+ return "off"
550550 case protocol.PickerLineStatusStale:
551- return "[stale]"
551+ return "stale"
552552 case protocol.PickerLineStatusVersion:
553- return "[version]"
553+ return "ver"
554554 case protocol.PickerLineStatusError:
555- return "[error]"
555+ return "err"
556556 case protocol.PickerLineStatusNoDaemon:
557- return "[no daemon]"
557+ return "none"
558558 default:
559559 return ""
560560 }
@@ -635,15 +635,17 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
635635 // A session header rings when any of its tabs does.
636636 x = ui.DrawText(frame, x, rect.Y+y, contentClipX, " "+string(ui.AttentionGlyph), base)
637637 }
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)
640645 }
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)
644646 if badge != "" {
645647 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)
647649 }
648650 continue
649651 }
@@ -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 continue650 continue
649 }651 }
M internal/usecase/picker/model_test.go +4 -4
@@ -288,7 +288,7 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) {
288288 m := New([]protocol.PickerLine{
289289 {
290290 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",
292292 },
293293 {
294294 Key: "b/old", Kind: protocol.PickerLineSession, Label: "old", Stopped: true,
@@ -299,8 +299,8 @@ 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.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)), " "))
304304 }
305305
306306 func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) {
@@ -310,7 +310,7 @@ func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) {
310310 Dim: true, Focusable: true, Actions: protocol.PickerCanNavigate,
311311 }}, Config{Intent: protocol.PickerIntentNavigation})
312312 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")
314314 require.Contains(t, rowText(frame.Row(7)), "no daemon")
315315 require.Contains(t, rowText(frame.Row(7)), "Enter to create a session")
316316 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")

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