Skip to content

Commit f06a491

Browse files
jongioCopilot
andauthored
refactor: compact session list row layout (#155)
* refactor: compact session list row layout Merge checkMark and indicator into a single 2-char selector column, reduce tree-mode indent from 4 to 2 spaces, and make the host icon a fixed 2-char column for consistent alignment. Extract applyRowStyle helper to eliminate duplicated style-application blocks. Add tests for narrow-width rendering, tree-mode indent, and selector state transitions. Co-authored-by: Copilot <[email protected]> * chore: update indirect dependencies - charmbracelet/ultraviolet: 20260615 -> 20260622 - charmbracelet/x/exp/slice: 20260621 -> 20260622 Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Copilot <[email protected]>
1 parent 3962483 commit f06a491

4 files changed

Lines changed: 180 additions & 49 deletions

File tree

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ require (
2929
// glamour transitively requires lipgloss v1; tracked in issue #59.
3030
// Upgrade glamour when lipgloss-v2-compatible release is available.
3131
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
32-
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect
32+
github.com/charmbracelet/ultraviolet v0.0.0-20260622092850-f39628c8a989 // indirect
3333
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
3434
github.com/charmbracelet/x/exp/golden v0.0.0-20260517005351-920740d613be // indirect
35-
github.com/charmbracelet/x/exp/slice v0.0.0-20260621010513-945fab64fd3e // indirect
35+
github.com/charmbracelet/x/exp/slice v0.0.0-20260622092256-25656177ba8e // indirect
3636
github.com/charmbracelet/x/term v0.2.2 // indirect
3737
github.com/charmbracelet/x/termios v0.1.1 // indirect
3838
github.com/charmbracelet/x/windows v0.2.2 // indirect

go.sum

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,16 @@ github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WK
2828
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
2929
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
3030
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
31-
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk=
32-
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
31+
github.com/charmbracelet/ultraviolet v0.0.0-20260622092850-f39628c8a989 h1:aLA9AmFNKnFr86XM3/Jm9g4xLOVjEgRuttBWUFujdVw=
32+
github.com/charmbracelet/ultraviolet v0.0.0-20260622092850-f39628c8a989/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
3333
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
3434
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
3535
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
3636
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
3737
github.com/charmbracelet/x/exp/golden v0.0.0-20260517005351-920740d613be h1:aiPiir7v0uEiTznth3vDt2LflviKiQfRguiU380Hyxo=
3838
github.com/charmbracelet/x/exp/golden v0.0.0-20260517005351-920740d613be/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0=
39-
github.com/charmbracelet/x/exp/slice v0.0.0-20260621010513-945fab64fd3e h1:oTYyM35VoAOgBRu2eklwR7JaZYV3jquTjjjyuDPhVpw=
40-
github.com/charmbracelet/x/exp/slice v0.0.0-20260621010513-945fab64fd3e/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
39+
github.com/charmbracelet/x/exp/slice v0.0.0-20260622092256-25656177ba8e h1:TerxTCW/3BrnpqKxeouW0cWSjZb7PsVAmtNftRAAFRE=
40+
github.com/charmbracelet/x/exp/slice v0.0.0-20260622092256-25656177ba8e/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
4141
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
4242
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
4343
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=

internal/tui/components/sessionlist.go

Lines changed: 47 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -721,41 +721,51 @@ func (s SessionList) renderSessionRow(sess data.Session, selected bool, hidden b
721721
relTime := RelativeTime(sess.LastActiveAt)
722722
turns := strconv.Itoa(sess.TurnCount) + "t"
723723

724-
// Attention dot — 2 chars (dot + space).
724+
// ── Row layout ─────────────────────────────────────────────────────
725+
// [indent 0|2][selector 2][att 2][host 2][plan 2][work 2] summary …
726+
//
727+
// The selector merges cursor pointer and multi-select check into one
728+
// 2-char column, saving 2 chars vs the old separate columns. Combined
729+
// with the reduced tree indent (2 vs 4), the prefix is 4 chars shorter
730+
// than before.
731+
732+
// Selector: combines cursor pointer and multi-select check into 2 chars.
733+
isChecked := s.IsSelected(sess.ID)
734+
var selector string
735+
switch {
736+
case isChecked && selected:
737+
selector = styles.IconCheck() + " "
738+
case isChecked:
739+
selector = styles.IconCheck() + " "
740+
case selected:
741+
selector = " " + styles.IconPointer()
742+
default:
743+
selector = " "
744+
}
745+
746+
// Status dots — each is a fixed 2 chars (icon + space) for alignment.
725747
attDot := s.attentionDot(sess.ID, selected)
726748

727-
// Host type icon — 2 chars (icon + space) if set, else empty.
728-
hostIcon := styles.IconHostType(sess.HostType)
729-
hostDotW := 0
730-
if hostIcon != "" {
731-
hostIcon += " "
732-
hostDotW = 2
749+
hostDot := styles.IconHostType(sess.HostType)
750+
if hostDot != "" {
751+
hostDot += " "
752+
} else {
753+
hostDot = " "
733754
}
734755

735-
// Plan dot — 2 chars (dot + space) if plan exists, else 2 spaces.
736756
plnDot := s.planDot(sess.ID, selected)
737-
738-
// Work status dot — 2 chars (icon + space) if work status known, else 2 spaces.
739757
wrkDot := s.workStatusDot(sess.ID, selected)
740758

741759
// In tree mode, indent sessions under their folder.
742760
indent := ""
743761
if s.treeMode {
744-
indent = " "
745-
w -= 4
746-
}
747-
748-
indicator := " "
749-
if selected {
750-
indicator = styles.IconPointer() + " "
751-
}
752-
// Show check mark for multi-selected sessions.
753-
checkMark := " "
754-
if s.IsSelected(sess.ID) {
755-
checkMark = styles.IconCheck() + " "
762+
indent = " "
763+
w -= 2
756764
}
757765

766+
const selectorW = 2
758767
const dotW = 2 // attention dot + space
768+
const hostDotW = 2 // host icon + space (always reserved)
759769
const planDotW = 2 // plan dot + space
760770
const wrkDotW = 2 // work status dot + space
761771
const timeW = 9
@@ -764,18 +774,9 @@ func (s SessionList) renderSessionRow(sess data.Session, selected bool, hidden b
764774

765775
// Very narrow terminal: summary + time only.
766776
if w < 50 {
767-
summaryW := max(10, w-2-dotW-hostDotW-planDotW-wrkDotW-2-timeW-spacing)
768-
line := indent + checkMark + indicator + attDot + hostIcon + plnDot + wrkDot + PadRight(summary, summaryW) + " " + PadLeft(relTime, timeW)
769-
if selected {
770-
return styles.SelectedStyle.Render(PadToWidth(line, s.width))
771-
}
772-
if hidden {
773-
return styles.HiddenStyle.Render(PadToWidth(line, s.width))
774-
}
775-
if favorited {
776-
return styles.FavoritedStyle.Render(PadToWidth(line, s.width))
777-
}
778-
return lipgloss.NewStyle().Render(PadToWidth(line, s.width))
777+
summaryW := max(10, w-selectorW-dotW-hostDotW-planDotW-wrkDotW-timeW-spacing)
778+
line := indent + selector + attDot + hostDot + plnDot + wrkDot + PadRight(summary, summaryW) + " " + PadLeft(relTime, timeW)
779+
return s.applyRowStyle(line, selected, hidden, favorited)
779780
}
780781

781782
// Show folder/repo columns at wider terminals.
@@ -787,7 +788,7 @@ func (s SessionList) renderSessionRow(sess data.Session, selected bool, hidden b
787788
folderW = 18
788789
}
789790

790-
summaryW := w - 2 - dotW - hostDotW - planDotW - wrkDotW - 2 - timeW - turnsW - 2*spacing
791+
summaryW := w - selectorW - dotW - hostDotW - planDotW - wrkDotW - timeW - turnsW - 2*spacing
791792
if folderW > 0 {
792793
summaryW -= folderW + spacing
793794
}
@@ -800,10 +801,9 @@ func (s SessionList) renderSessionRow(sess data.Session, selected bool, hidden b
800801

801802
var b strings.Builder
802803
b.WriteString(indent)
803-
b.WriteString(checkMark)
804-
b.WriteString(indicator)
804+
b.WriteString(selector)
805805
b.WriteString(attDot)
806-
b.WriteString(hostIcon)
806+
b.WriteString(hostDot)
807807
b.WriteString(plnDot)
808808
b.WriteString(wrkDot)
809809
b.WriteString(PadRight(summary, summaryW))
@@ -824,18 +824,22 @@ func (s SessionList) renderSessionRow(sess data.Session, selected bool, hidden b
824824
b.WriteString(" ")
825825
b.WriteString(PadLeft(turns, turnsW))
826826

827-
line := b.String()
827+
return s.applyRowStyle(b.String(), selected, hidden, favorited)
828+
}
828829

830+
// applyRowStyle renders a row line with the appropriate style based on state.
831+
func (s SessionList) applyRowStyle(line string, selected, hidden, favorited bool) string {
832+
padded := PadToWidth(line, s.width)
829833
if selected {
830-
return styles.SelectedStyle.Render(PadToWidth(line, s.width))
834+
return styles.SelectedStyle.Render(padded)
831835
}
832836
if hidden {
833-
return styles.HiddenStyle.Render(PadToWidth(line, s.width))
837+
return styles.HiddenStyle.Render(padded)
834838
}
835839
if favorited {
836-
return styles.FavoritedStyle.Render(PadToWidth(line, s.width))
840+
return styles.FavoritedStyle.Render(padded)
837841
}
838-
return lipgloss.NewStyle().Render(PadToWidth(line, s.width))
842+
return lipgloss.NewStyle().Render(padded)
839843
}
840844

841845
// renderDot returns a styled 2-character string (icon + space). When selected

internal/tui/components/sessionlist_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,3 +1204,130 @@ func TestRenderSessionRow_HostTypeWidthConsistency(t *testing.T) {
12041204
sl.MoveDown()
12051205
}
12061206
}
1207+
1208+
// ---------------------------------------------------------------------------
1209+
// Narrow-width rendering (w < 50)
1210+
// ---------------------------------------------------------------------------
1211+
1212+
func TestRenderSessionRow_NarrowWidth(t *testing.T) {
1213+
t.Parallel()
1214+
const width = 40
1215+
const height = 10
1216+
1217+
sessions := []data.Session{
1218+
{ID: "narrow-1", Summary: "Short summary", TurnCount: 2, LastActiveAt: "2025-01-01T00:00:00Z"},
1219+
{ID: "narrow-2", Summary: "A much longer summary that should be truncated at narrow width", TurnCount: 5, LastActiveAt: "2025-01-01T00:00:00Z", HostType: "cli"},
1220+
}
1221+
1222+
sl := NewSessionList()
1223+
sl.SetSessions(sessions)
1224+
sl.SetSize(width, height)
1225+
1226+
view := sl.View()
1227+
lines := strings.Split(view, "\n")
1228+
if len(lines) != height {
1229+
t.Fatalf("View() has %d lines, want %d", len(lines), height)
1230+
}
1231+
1232+
for i, line := range lines {
1233+
plain := stripAnsi(line)
1234+
pw := len([]rune(plain))
1235+
if pw != width {
1236+
t.Errorf("line %d: width=%d want %d, line=%q", i, pw, width, plain)
1237+
}
1238+
}
1239+
}
1240+
1241+
// ---------------------------------------------------------------------------
1242+
// Tree-mode indent (2 spaces)
1243+
// ---------------------------------------------------------------------------
1244+
1245+
func TestRenderSessionRow_TreeModeIndent(t *testing.T) {
1246+
t.Parallel()
1247+
const width = 100
1248+
const height = 10
1249+
1250+
groups := makeGroups(1, 2)
1251+
sl := NewSessionList()
1252+
sl.SetGroups(groups)
1253+
1254+
// Expand the folder to show children.
1255+
sl.ExpandAll()
1256+
sl.SetSize(width, height)
1257+
1258+
view := sl.View()
1259+
lines := strings.Split(view, "\n")
1260+
1261+
// Line 0 is the folder header, line 1 is the first session.
1262+
if len(lines) < 3 {
1263+
t.Fatalf("expected at least 3 lines, got %d", len(lines))
1264+
}
1265+
1266+
// Session lines (index 1, 2) should start with 2-space indent.
1267+
for _, idx := range []int{1, 2} {
1268+
plain := stripAnsi(lines[idx])
1269+
if len(plain) < 2 {
1270+
t.Fatalf("line %d too short: %q", idx, plain)
1271+
}
1272+
if plain[:2] != " " {
1273+
t.Errorf("line %d: expected 2-space indent, got prefix %q", idx, plain[:4])
1274+
}
1275+
pw := len([]rune(plain))
1276+
if pw != width {
1277+
t.Errorf("line %d: width=%d want %d", idx, pw, width)
1278+
}
1279+
}
1280+
}
1281+
1282+
// ---------------------------------------------------------------------------
1283+
// Selector column: all 4 states
1284+
// ---------------------------------------------------------------------------
1285+
1286+
func TestRenderSessionRow_SelectorStates(t *testing.T) {
1287+
t.Parallel()
1288+
const width = 100
1289+
const height = 10
1290+
1291+
sessions := []data.Session{
1292+
{ID: "sel-1", Summary: "First session", TurnCount: 1, LastActiveAt: "2025-01-01T00:00:00Z"},
1293+
{ID: "sel-2", Summary: "Second session", TurnCount: 2, LastActiveAt: "2025-01-01T00:00:00Z"},
1294+
{ID: "sel-3", Summary: "Third session", TurnCount: 3, LastActiveAt: "2025-01-01T00:00:00Z"},
1295+
}
1296+
1297+
sl := NewSessionList()
1298+
sl.SetSessions(sessions)
1299+
sl.SetSize(width, height)
1300+
1301+
// All lines should have consistent width in each state.
1302+
// State 1: cursor on first, nothing multi-selected.
1303+
view := sl.View()
1304+
for i, line := range strings.Split(view, "\n") {
1305+
plain := stripAnsi(line)
1306+
pw := len([]rune(plain))
1307+
if pw != width {
1308+
t.Errorf("state1 line %d: width=%d want %d", i, pw, width)
1309+
}
1310+
}
1311+
1312+
// State 2: multi-select first row, cursor stays on first.
1313+
sl.ToggleSelected()
1314+
view = sl.View()
1315+
for i, line := range strings.Split(view, "\n") {
1316+
plain := stripAnsi(line)
1317+
pw := len([]rune(plain))
1318+
if pw != width {
1319+
t.Errorf("state2 line %d: width=%d want %d", i, pw, width)
1320+
}
1321+
}
1322+
1323+
// State 3: move cursor to second, first is still multi-selected (check only).
1324+
sl.MoveDown()
1325+
view = sl.View()
1326+
for i, line := range strings.Split(view, "\n") {
1327+
plain := stripAnsi(line)
1328+
pw := len([]rune(plain))
1329+
if pw != width {
1330+
t.Errorf("state3 line %d: width=%d want %d", i, pw, width)
1331+
}
1332+
}
1333+
}

0 commit comments

Comments
 (0)