clone

a lazygit-style terminal viewer for openspec repos: watch changes, tasks and spec deltas land as agents work

Add lazygit-style vertical stack (portrait) layout with squashed side panels

Dami committed 7162076
commit
71620767be9575efe680336fce5bd698a1af331e
parent
6aa4b1d8bcbd1213997a6d363427da4ddd9b8113
author
Dami <dami.etoile@gmail.com>
6 files changed, +238 -46
MREADME.md12+++
Minternal/ui/layout.go130+++++++++++++++++++++++++-----
Minternal/ui/model.go32+++++--
Minternal/ui/model_test.go74+++++++++++++++++-
Minternal/ui/render.go27++++--
Mmain.go9++-
M README.md +12 -0
@@ -45,6 +45,7 @@ lazyopenspec # finds openspec/ in the current dir or any parent
4545 lazyopenspec ~/code/repo # or point it at a repo4646 lazyopenspec --follow # start in follow mode4747 lazyopenspec --interval 1s # poll less often (default 400ms)48+lazyopenspec --portrait always # always stack panels above the main pane (auto | always | never)4849 ```4950 5051 Requires Go 1.27+. `git` is optional: without it, the uncommitted markers and diff view are unavailable.
@@ -77,6 +78,7 @@ Git is run with `GIT_OPTIONAL_LOCKS=0`, so the viewer never holds `.git/index.lo
7778 | `f` | follow mode: jump to the item and file the agents touch next |7879 | `s` | sort changes by name / most recently edited |7980 | `z` | zoom the main pane |81+| `v` | toggle the vertical stack (portrait) layout |8082 | `e` | open the current file in `$VISUAL` / `$EDITOR` |8183 | `c` | clear the activity feed |8284 | `r` | force reload |
@@ -85,6 +87,16 @@ Git is run with `GIT_OPTIONAL_LOCKS=0`, so the viewer never holds `.git/index.lo
8587 8688 Mouse: wheel scrolls the pane under the pointer, and clicking selects a row.8789 90+## Vertical stack (portrait) mode91+92+Works like lazygit's `portraitMode`. In a tall, narrow pane (84 columns or fewer and 46 rows or more, lazygit's93+defaults), the list panels stack on top and take a third of the height, and the main pane gets the rest at full94+width. `--portrait always|never` overrides the auto rule, and `v` flips the layout while it runs.95+96+When the panels are short on space, which is the usual case when stacked, lazygit's accordion behavior applies: the97+focused panel expands and the others shrink to their selected row, or to just their title if space is very tight.98+Tab between panels to expand each one in turn.99+88100 ## How it watches89101 90102 It does a stat-only walk of `openspec/` every `--interval` and re-parses only when something changed. It doesn't
M internal/ui/layout.go +107 -23
@@ -15,8 +15,10 @@ func (r rect) contains(x, y int) bool {
1515 1616 // geometry is the screen layout for one frame. Panel rects include borders.1717 type geometry struct {18- panels [numLists]rect19- main rect18+ body rect // everything between the header and footer19+ panels [numLists]rect20+ main rect21+ portrait bool // side panels stacked above the main pane2022 }2123 2224 const (
@@ -24,36 +26,115 @@ const (
2426 footerRows = 12527 )2628 29+// PortraitMode mirrors lazygit's gui.portraitMode: whether to stack the side30+// panels on top of the main pane instead of beside it.31+type PortraitMode string32+33+const (34+ PortraitAuto PortraitMode = "auto"35+ PortraitAlways PortraitMode = "always"36+ PortraitNever PortraitMode = "never"37+)38+39+// lazygit's defaults for gui.portraitModeAutoMaxWidth / portraitModeAutoMinHeight.40+const (41+ portraitAutoMaxWidth = 8442+ portraitAutoMinHeight = 4643+)44+45+// ParsePortraitMode validates a --portrait flag value.46+func ParsePortraitMode(s string) (PortraitMode, bool) {47+ switch p := PortraitMode(s); p {48+ case PortraitAuto, PortraitAlways, PortraitNever:49+ return p, true50+ }51+ return "", false52+}53+54+func (m Model) portrait() bool {55+ switch m.portraitMode {56+ case PortraitAlways:57+ return true58+ case PortraitNever:59+ return false60+ default:61+ return m.width <= portraitAutoMaxWidth && m.height >= portraitAutoMinHeight62+ }63+}64+2765 func (m Model) geometry() geometry {28- var g geometry66+ g := geometry{portrait: m.portrait()}2967 bodyH := max(m.height-headerRows-footerRows, 0)30- leftW := clamp(m.width*38/100, 34, 60)68+ g.body = rect{0, headerRows, m.width, bodyH}69+70+ // Side section beside the main pane, or (portrait) above it taking a71+ // third of the height, like lazygit's default sidePanelWidth of 0.3333.72+ side := rect{0, headerRows, clamp(m.width*38/100, 34, 60), bodyH}3173 if m.width < 80 {32- leftW = m.width / 274+ side.w = m.width / 275+ }76+ g.main = rect{side.w, headerRows, m.width - side.w, bodyH}77+ if g.portrait {78+ side = rect{0, headerRows, m.width, bodyH / 3}79+ g.main = rect{0, headerRows + side.h, m.width, bodyH - side.h}80+ }81+82+ y := side.y83+ for i, h := range m.sideHeights(side.h) {84+ g.panels[i] = rect{side.x, y, side.w, h}85+ y += h3386 }3487 if m.zoom {35- leftW = 088+ g.main = g.body3689 }90+ return g91+}3792 38- // Content rows available to the four list panels after their borders.39- avail := max(bodyH-2*numLists, numLists)40- want := [numLists]int{41- max(m.count(panelChanges), 1),42- max(m.count(panelSpecs), 1),43- max(m.count(panelArchive), 1),44- max(m.count(panelActivity), 1),93+// sideFocus is the side panel lazygit would treat as current: the focused94+// list, or the one driving the main pane while the main pane has focus.95+func (m Model) sideFocus() panelID {96+ if m.focus < numLists {97+ return m.focus4598 }46- share := [numLists]int{35, 25, 15, 25}47- rows := allocate(want, share, avail, m.focus)99+ return m.lastList100+}48101 49- y := headerRows50- for i := range numLists {51- h := rows[i] + 252- g.panels[i] = rect{0, y, leftW, h}53- y += h102+// sideHeights splits the side section between the list panels (heights103+// include borders). With enough room each panel gets a share of the height;104+// otherwise, as in lazygit, the focused panel takes the space and the others105+// squash to a single row, or just their title when space is very tight.106+func (m Model) sideHeights(total int) [numLists]int {107+ // lazygit's thresholds (28 and 21 rows for five panels), scaled to four.108+ const (109+ minNormal = 28 * numLists / 5110+ minTallSquash = 21 * numLists / 5111+ squashedTall = 3 // border, selected row, border112+ squashedShort = 1 // title only113+ )114+ var heights [numLists]int115+ if total >= minNormal {116+ want := [numLists]int{117+ max(m.count(panelChanges), 1),118+ max(m.count(panelSpecs), 1),119+ max(m.count(panelArchive), 1),120+ max(m.count(panelActivity), 1),121+ }122+ share := [numLists]int{35, 25, 15, 25}123+ for i, rows := range allocate(want, share, total-2*numLists, m.focus) {124+ heights[i] = rows + 2125+ }126+ return heights54127 }55- g.main = rect{leftW, headerRows, m.width - leftW, bodyH}56- return g128+ squashed := squashedShort129+ if total >= minTallSquash {130+ squashed = squashedTall131+ }132+ focused := m.sideFocus()133+ for i := range heights {134+ heights[i] = squashed135+ }136+ heights[focused] = max(total-squashed*(numLists-1), 0)137+ return heights57138 }58139 59140 // allocate splits total rows between panels: each gets its percentage share,
@@ -90,7 +171,7 @@ func allocate(want, share [numLists]int, total int, focus panelID) [numLists]int
90171 // box draws a rounded border around body with a title embedded in the top91172 // edge and an optional footer in the bottom edge, lazygit-style.92173 func box(t theme, title, footer, body string, w, h int, focused bool) string {93- if w < 2 || h < 2 {174+ if w < 2 || h < 1 {94175 return ""95176 }96177 border := t.subtle
@@ -112,6 +193,9 @@ func box(t theme, title, footer, body string, w, h int, focused bool) string {
112193 113194 var sb strings.Builder114195 sb.WriteString(edge(b.TopLeft, b.TopRight, title))196+ if h == 1 {197+ return sb.String() // squashed panel: title only198+ }115199 lines := strings.Split(body, "\n")116200 for i := range h - 2 {117201 line := ""
M internal/ui/model.go +22 -10
@@ -44,6 +44,7 @@ type Options struct {
4444 Root string4545 Interval time.Duration4646 Follow bool47+ Portrait PortraitMode // stack panels above the main pane: auto, always, never4748 }4849 4950 type (
@@ -79,6 +80,7 @@ type Model struct {
7980 diffMode bool8081 zoom bool8182 showHelp bool83+ portraitMode PortraitMode8284 8385 snap *spec.Snapshot8486 fp spec.Fingerprint
@@ -110,16 +112,17 @@ func New(opts Options) Model {
110112 vp := viewport.New()111113 vp.MouseWheelEnabled = false // the root model routes wheel events112114 return Model{113- opts: opts,114- theme: newTheme(true),115- follow: opts.Follow,116- vp: vp,117- tabs: map[string]string{},118- scroll: map[string]int{},119- renderer: newMDRenderer(),120- diffs: map[string]diffMsg{},121- diffReq: map[string]bool{},122- now: time.Now(),115+ opts: opts,116+ theme: newTheme(true),117+ follow: opts.Follow,118+ portraitMode: opts.Portrait,119+ vp: vp,120+ tabs: map[string]string{},121+ scroll: map[string]int{},122+ renderer: newMDRenderer(),123+ diffs: map[string]diffMsg{},124+ diffReq: map[string]bool{},125+ now: time.Now(),123126 }124127 }125128
@@ -440,6 +443,14 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
440443 case "z":441444 m.zoom = !m.zoom442445 m.clampAll()446+ case "v":447+ // Flip the current layout; this pins it until toggled again.448+ if m.portrait() {449+ m.portraitMode = PortraitNever450+ } else {451+ m.portraitMode = PortraitAlways452+ }453+ m.clampAll()443454 case "r":444455 m.loading = true445456 return tea.Batch(m.loadCmd(), m.refreshGit())
@@ -455,6 +466,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
455466 456467 func (m *Model) setFocus(p panelID) {457468 m.focus = p469+ defer m.clampAll() // squashed panel heights depend on focus458470 if p < numLists && p != panelActivity {459471 m.lastList = p460472 }
M internal/ui/model_test.go +72 -2
@@ -44,9 +44,9 @@ func key(s string) tea.KeyPressMsg {
4444 4545 // Every frame must be exactly the terminal size, at any size, in any panel.4646 func TestViewFitsTerminal(t *testing.T) {47- for _, size := range [][2]int{{160, 45}, {80, 24}, {40, 10}, {20, 5}, {3, 2}, {1, 1}} {47+ for _, size := range [][2]int{{160, 45}, {80, 24}, {84, 46}, {60, 90}, {40, 10}, {20, 5}, {3, 2}, {1, 1}} {4848 m := fixtureModel(t, size[0], size[1])49- for _, k := range []string{"", "tab", "j", "]", "z", "?", "x", "2", "enter", "d"} {49+ for _, k := range []string{"", "tab", "j", "]", "z", "?", "x", "2", "enter", "d", "v", "tab", "v"} {5050 if k != "" {5151 m = step(t, m, key(k))5252 }
@@ -110,3 +110,73 @@ func TestAllocateFillsTotal(t *testing.T) {
110110 }111111 }112112 }113+114+func TestPortraitAuto(t *testing.T) {115+ // lazygit's defaults: portrait when width <= 84 and height >= 46.116+ tests := []struct {117+ w, h int118+ want bool119+ }{120+ {84, 46, true},121+ {60, 120, true},122+ {85, 46, false},123+ {84, 45, false},124+ {160, 45, false},125+ }126+ for _, tt := range tests {127+ if got := fixtureModel(t, tt.w, tt.h).geometry().portrait; got != tt.want {128+ t.Errorf("%dx%d: portrait = %v, want %v", tt.w, tt.h, got, tt.want)129+ }130+ }131+ m := fixtureModel(t, 160, 45)132+ m.portraitMode = PortraitAlways133+ if !m.geometry().portrait {134+ t.Error("always: not portrait at 160x45")135+ }136+ m = fixtureModel(t, 60, 120)137+ m.portraitMode = PortraitNever138+ if m.geometry().portrait {139+ t.Error("never: portrait at 60x120")140+ }141+}142+143+func TestPortraitStacksPanelsAboveMain(t *testing.T) {144+ m := fixtureModel(t, 80, 62) // body 60: side 20, main 40145+ g := m.geometry()146+ if g.main.x != 0 || g.main.w != 80 || g.main.y != headerRows+20 || g.main.h != 40 {147+ t.Errorf("main = %+v, want full width below a 20-row side section", g.main)148+ }149+ y := headerRows150+ for p, r := range g.panels {151+ if r.x != 0 || r.w != 80 || r.y != y {152+ t.Errorf("panel %d = %+v, want full width at y=%d", p, r, y)153+ }154+ y += r.h155+ }156+ if y != g.main.y {157+ t.Errorf("panels end at y=%d, main starts at %d", y, g.main.y)158+ }159+}160+161+func TestSquashedSidePanels(t *testing.T) {162+ // A 20-row side section is below the normal-layout threshold (22) but163+ // tall enough (>= 16) for 3-row squashed panels, like lazygit.164+ m := fixtureModel(t, 80, 62)165+ if got := m.sideHeights(20); got != [numLists]int{11, 3, 3, 3} {166+ t.Errorf("focused Changes: heights %v", got)167+ }168+ m = step(t, m, key("2"))169+ if got := m.sideHeights(20); got != [numLists]int{3, 11, 3, 3} {170+ t.Errorf("focused Specs: heights %v", got)171+ }172+ m = step(t, m, key("0")) // main focused: Specs still drives it and stays open173+ if got := m.sideHeights(20); got != [numLists]int{3, 11, 3, 3} {174+ t.Errorf("main focused: heights %v", got)175+ }176+ if got := m.sideHeights(12); got != [numLists]int{1, 9, 1, 1} {177+ t.Errorf("short section: heights %v, want title-only squashed panels", got)178+ }179+ if got := m.sideHeights(40); got[0] < 4 || got[1] < 4 {180+ t.Errorf("tall section should not squash: %v", got)181+ }182+}
M internal/ui/render.go +17 -10
@@ -18,20 +18,26 @@ func (m Model) render() string {
1818 return ""1919 }2020 g := m.geometry()21- var cols []string22- if m.showHelp {23- cols = append(cols, box(m.theme, m.theme.title.Render("Keys"), "", m.helpText(m.width-2), m.width, g.main.h, true))24- } else if !m.zoom {21+ var body string22+ switch {23+ case m.showHelp:24+ body = box(m.theme, m.theme.title.Render("Keys"), "", m.helpText(g.body.w-2), g.body.w, g.body.h, true)25+ case m.zoom:26+ body = m.renderMain(g.main)27+ default:2528 panels := make([]string, 0, numLists)2629 for p := range panelID(numLists) {27- panels = append(panels, m.renderPanel(p, g.panels[p]))30+ if r := g.panels[p]; r.h > 0 {31+ panels = append(panels, m.renderPanel(p, r))32+ }33+ }34+ side := lipgloss.JoinVertical(lipgloss.Left, panels...)35+ if g.portrait {36+ body = lipgloss.JoinVertical(lipgloss.Left, side, m.renderMain(g.main))37+ } else {38+ body = lipgloss.JoinHorizontal(lipgloss.Top, side, m.renderMain(g.main))2839 }29- cols = append(cols, lipgloss.JoinVertical(lipgloss.Left, panels...))30- }31- if !m.showHelp {32- cols = append(cols, m.renderMain(g.main))3340 }34- body := lipgloss.JoinHorizontal(lipgloss.Top, cols...)3541 3642 lines := strings.Split(m.renderHeader()+"\n"+body, "\n")3743 if len(lines) > m.height-footerRows {
@@ -130,6 +136,7 @@ func (m Model) helpText(w int) string {
130136 {"f", "follow mode: jump to whatever the agents touch next"},131137 {"s", "sort changes by name / most recently edited"},132138 {"z", "zoom main pane"},139+ {"v", "toggle vertical stack (portrait) layout"},133140 {"c", "clear activity log"},134141 {"r", "force reload"},135142 {"q", "quit"},
M main.go +8 -1