clone

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

main
lazyopenspec / internal / ui / render.go
render.go 12.3K
1package ui23import (4 "fmt"5 "os"6 "path/filepath"7 "strings"8 "time"910 "charm.land/lipgloss/v2"11 "github.com/charmbracelet/x/ansi"1213 "src.rest/cat/lazyopenspec/internal/spec"14)1516func (m Model) render() string {17 if m.width <= 0 || m.height <= 0 {18 return ""19 }20 g := m.geometry()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:28 panels := make([]string, 0, numLists)29 for p := range panelID(numLists) {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))39 }40 }4142 lines := strings.Split(m.renderHeader()+"\n"+body, "\n")43 if len(lines) > m.height-footerRows {44 lines = lines[:max(m.height-footerRows, 0)]45 }46 for len(lines) < m.height-footerRows {47 lines = append(lines, "")48 }49 lines = append(lines, m.renderFooter())50 for i, l := range lines {51 lines[i] = ansi.Truncate(l, m.width, "")52 }53 return strings.Join(lines, "\n")54}5556func (m Model) renderHeader() string {57 th := m.theme58 sep := th.subtle.Render(" │ ")59 shown := m.opts.Display60 if shown == "" {61 shown = m.opts.Root62 }63 left := []string{th.title.Render(" lazyopenspec"), th.bold.Render(tildePath(shown))}64 if m.git.Branch != "" {65 branch := " " + m.git.Branch66 if n := len(m.git.Dirty); n > 0 {67 branch += th.modSt.Render(fmt.Sprintf(" +%d uncommitted", n))68 }69 left = append(left, th.renSt.Render(branch))70 }71 if m.snap != nil && m.snap.Exists {72 active, done := 0, 073 for _, c := range m.snap.Changes {74 active++75 if c.Tasks.Complete() {76 done++77 }78 }79 stats := fmt.Sprintf("%d active", active)80 if done > 0 {81 stats += fmt.Sprintf(", %d ready to archive", done)82 }83 left = append(left, th.muted.Render(stats))84 }8586 var right []string87 if m.follow {88 right = append(right, th.key.Render("FOLLOW"))89 }90 live := th.subtle.Render("●")91 switch {92 case m.loading:93 live = th.modSt.Render("●")94 case !m.lastSeen.IsZero() && m.now.Sub(m.lastSeen) < freshWindow:95 live = th.freshSt.Render("●")96 }97 status := "watching"98 if !m.lastSeen.IsZero() {99 status = "last edit " + ago(m.now, m.lastSeen)100 }101 right = append(right, live+" "+th.muted.Render(status)+" ")102 return spread(strings.Join(left, sep), strings.Join(right, sep), m.width)103}104105func (m Model) renderFooter() string {106 th := m.theme107 if m.err != nil {108 return th.remSt.Render(" error: " + m.err.Error())109 }110 hint := func(k, d string) string { return th.key.Render(k) + " " + th.keyDesc.Render(d) }111 var hints []string112 switch m.focus {113 case panelMain:114 hints = []string{hint("j/k", "scroll"), hint("[ ]", "tab"), hint("d", "diff"), hint("e", "edit"), hint("esc", "back")}115 case panelActivity:116 hints = []string{hint("j/k", "move"), hint("enter", "jump"), hint("c", "clear"), hint("[ ]", "tab")}117 default:118 hints = []string{hint("j/k", "move"), hint("enter", "read"), hint("[ ]", "tab"), hint("d", "diff"), hint("e", "edit")}119 if m.focus == panelChanges {120 hints = append(hints, hint("s", "sort"))121 }122 }123 hints = append(hints, hint("f", "follow"), hint("z", "zoom"), hint("?", "help"), hint("q", "quit"))124 return ansi.Truncate(" "+strings.Join(hints, " "), m.width, "…")125}126127func (m Model) helpText(w int) string {128 th := m.theme129 rows := [][2]string{130 {"tab / shift+tab", "cycle panels"},131 {"1 2 3 4 / 0", "jump to Changes, Specs, Archive, Activity / main"},132 {"j k / up down", "move selection, or scroll when main is focused"},133 {"g G", "top / bottom"},134 {"enter / l", "focus main pane (on Activity: jump to item)"},135 {"esc / h", "back to list"},136 {"J K / ctrl+d ctrl+u", "scroll main pane by half a page"},137 {"[ ]", "previous / next file tab"},138 {"d", "toggle git diff vs HEAD for the current file"},139 {"e", "open the current file in $VISUAL / $EDITOR"},140 {"f", "follow mode: jump to whatever the agents touch next"},141 {"s", "sort changes by name / most recently edited"},142 {"z", "zoom main pane"},143 {"v", "toggle vertical stack (portrait) layout"},144 {"c", "clear activity log"},145 {"r", "force reload"},146 {"q", "quit"},147 }148 var sb strings.Builder149 sb.WriteString("\n")150 for _, r := range rows {151 sb.WriteString(" " + th.key.Render(fmt.Sprintf("%-22s", r[0])) + th.keyDesc.Render(r[1]) + "\n")152 }153 sb.WriteString("\n " + th.muted.Render("Legend: ") +154 th.freshSt.Render("●") + th.muted.Render(" edited <15s ") +155 th.warmSt.Render("●") + th.muted.Render(" edited <2m ") +156 th.modSt.Render("M") + th.muted.Render(" uncommitted ") +157 th.muted.Render("P D S T = proposal, design, spec deltas, tasks"))158 sb.WriteString("\n\n " + th.subtle.Render("press any key to close"))159 return sb.String()160}161162func (m Model) renderPanel(p panelID, r rect) string {163 th := m.theme164 focused := m.focus == p165 n := m.count(p)166 title := fmt.Sprintf("[%d] %s", p+1, panelTitles[p])167 if p == panelChanges && m.sortRecent {168 title += " (recent)"169 }170 if focused {171 title = th.title.Render(title)172 } else {173 title = th.muted.Render(title)174 }175 footer := ""176 if n > 0 {177 footer = th.muted.Render(fmt.Sprintf("%d of %d", m.cursors[p]+1, n))178 }179180 w, h := r.w-2, r.h-2181 var rows []string182 if n == 0 {183 rows = append(rows, " "+th.subtle.Render(emptyText(p, m.snap)))184 }185 for i := m.offsets[p]; i < min(n, m.offsets[p]+h); i++ {186 sel := i == m.cursors[p]187 active := sel && (focused || (p == m.lastList && m.focus == panelMain))188 rows = append(rows, m.renderRow(p, i, w, sel, active))189 }190 return box(th, title, footer, strings.Join(rows, "\n"), r.w, r.h, focused)191}192193func emptyText(p panelID, snap *spec.Snapshot) string {194 if snap == nil {195 return "loading…"196 }197 switch p {198 case panelChanges:199 return "no active changes"200 case panelSpecs:201 return "no specs yet"202 case panelArchive:203 return "nothing archived"204 default:205 return "watching for edits…"206 }207}208209// painter renders styled cells, adding the selection background when the row210// is selected so inner style resets don't punch holes in the highlight.211type painter struct {212 bg bool213 th theme214 w int215 out strings.Builder216}217218func (p *painter) put(st lipgloss.Style, s string) {219 if p.bg {220 st = st.Background(p.th.selBg)221 }222 p.out.WriteString(st.Render(s))223}224225func (p *painter) String() string {226 s := ansi.Truncate(p.out.String(), p.w, "…")227 if pad := p.w - lipgloss.Width(s); pad > 0 {228 st := lipgloss.NewStyle()229 if p.bg {230 st = st.Background(p.th.selBg)231 }232 s += st.Render(strings.Repeat(" ", pad))233 }234 return s235}236237func (m Model) renderRow(p panelID, i, w int, sel, active bool) string {238 th := m.theme239 pt := &painter{bg: sel, th: th, w: w}240 nameSt := lipgloss.NewStyle().Foreground(th.text)241 if active {242 nameSt = nameSt.Bold(true).Foreground(th.accent)243 }244 cursor := " "245 if active {246 cursor = "▌"247 }248 pt.put(lipgloss.NewStyle().Foreground(th.accent), cursor)249250 switch p {251 case panelChanges, panelArchive:252 var c spec.Change253 if p == panelChanges {254 c = m.changes()[i]255 } else {256 c = m.snap.Archive[i]257 }258 label, used := c.ID, 3259 if p == panelArchive {260 label = c.Name261 if c.ArchivedOn != "" {262 pt.put(th.muted, c.ArchivedOn[5:]+" ")263 used += 6264 }265 }266 pt.put(m.freshStyle(c.ModTime), m.freshDot(c.ModTime))267 putLabeled(pt, w, used, label, nameSt, m.dirMark(c.Dir), m.dirMarkStyle(c.Dir), m.changeRowRight(c, w))268269 case panelSpecs:270 s := m.snap.Specs[i]271 pt.put(m.freshStyle(s.ModTime), m.freshDot(s.ModTime))272 var right segments273 if pending := len(m.pendingDeltas(s.ID)); pending > 0 {274 right = append(right, segment{th.modSt, fmt.Sprintf("Δ%d ", pending)})275 }276 right = append(right, segment{th.muted, fmt.Sprintf("%3d req", s.Requirements)})277 putLabeled(pt, w, 3, s.ID, nameSt, m.fileMark(s.Path), th.modSt, right)278279 case panelActivity:280 e := m.events[i]281 ts := th.muted282 if m.now.Sub(e.Time) < freshWindow {283 ts = th.freshSt284 }285 pt.put(ts, e.Time.Format("15:04:05")+" ")286 icon, st := eventIcon(th, e.Kind)287 pt.put(st, icon+" ")288 idW := clamp(w/3, 8, 24)289 id := e.ID290 if e.Item == spec.ItemArchive {291 id = spec.ArchiveName(id)292 }293 pt.put(nameSt, fmt.Sprintf("%-*s", idW, ansi.Truncate(id, idW, "…"))+" ")294 pt.put(th.muted, e.Detail)295 }296 return pt.String()297}298299// putLabeled writes a left-aligned label and right-aligned segments, with an300// optional git mark just before the segments. used is the width already301// written on the row.302func putLabeled(pt *painter, w, used int, label string, nameSt lipgloss.Style, mark string, markSt lipgloss.Style, right segments) {303 if mark != "" {304 mark += " "305 }306 rightW := right.Width() + lipgloss.Width(mark)307 label = ansi.Truncate(label, max(w-used-rightW-1, 1), "…")308 pt.put(nameSt, label)309 pt.put(lipgloss.NewStyle(), strings.Repeat(" ", max(w-used-lipgloss.Width(label)-rightW, 1)))310 if mark != "" {311 pt.put(markSt, mark)312 }313 for _, seg := range right {314 pt.put(seg.st, seg.s)315 }316}317318type segment struct {319 st lipgloss.Style320 s string321}322323// changeRowRight is the "PDST ▰▰▱ 3/5" block on a change row.324func (m Model) changeRowRight(c spec.Change, w int) segments {325 th := m.theme326 var segs segments327 for _, k := range []struct {328 kind spec.ArtifactKind329 letter string330 }{{spec.KindProposal, "P"}, {spec.KindDesign, "D"}, {spec.KindDelta, "S"}, {spec.KindTasks, "T"}} {331 st := th.subtle332 if c.Has(k.kind) {333 st = th.title334 }335 segs = append(segs, segment{st, k.letter})336 }337 if w >= 36 {338 segs = append(segs, segment{lipgloss.NewStyle(), " "})339 bar := 5340 filled := 0341 if c.Tasks.Total > 0 {342 filled = c.Tasks.Done * bar / c.Tasks.Total343 }344 fill := th.title345 if c.Tasks.Complete() {346 fill = th.addSt347 }348 segs = append(segs, segment{fill, strings.Repeat("▰", filled)}, segment{th.subtle, strings.Repeat("▱", bar-filled)})349 }350 count := ""351 if c.Tasks.Total > 0 {352 count = fmt.Sprintf("%d/%d", c.Tasks.Done, c.Tasks.Total)353 }354 st := th.muted355 if c.Tasks.Complete() {356 st = th.addSt357 }358 segs = append(segs, segment{st, fmt.Sprintf(" %5s", count)})359 return segs360}361362type segments []segment363364func (s segments) Width() int {365 n := 0366 for _, seg := range s {367 n += lipgloss.Width(seg.s)368 }369 return n370}371372func eventIcon(th theme, k spec.EventKind) (string, lipgloss.Style) {373 switch k {374 case spec.EventCreated:375 return "+", th.addSt376 case spec.EventProgress:377 return "▸", th.title378 case spec.EventArchived:379 return "✓", th.renSt380 case spec.EventRemoved:381 return "-", th.remSt382 default:383 return "~", th.modSt384 }385}386387func (m Model) freshDot(t time.Time) string {388 if m.now.Sub(t) < warmWindow {389 return "● "390 }391 return " "392}393394func (m Model) freshStyle(t time.Time) lipgloss.Style {395 if m.now.Sub(t) < freshWindow {396 return m.theme.freshSt397 }398 return m.theme.warmSt399}400401// dirMark summarizes git status for files under a change dir.402func (m Model) dirMark(dir string) string {403 prefix := dir + string(filepath.Separator)404 untracked, modified := false, false405 for path, code := range m.git.Dirty {406 if !strings.HasPrefix(path, prefix) {407 continue408 }409 if code == "??" {410 untracked = true411 } else {412 modified = true413 }414 }415 switch {416 case modified:417 return "M"418 case untracked:419 return "+"420 }421 return ""422}423424func (m Model) dirMarkStyle(dir string) lipgloss.Style {425 if m.dirMark(dir) == "+" {426 return m.theme.addSt427 }428 return m.theme.modSt429}430431func (m Model) fileMark(path string) string {432 switch code, ok := m.git.Dirty[path]; {433 case !ok:434 return ""435 case code == "??":436 return "+"437 default:438 return "M"439 }440}441442// spread places left and right on one line of width w.443func spread(left, right string, w int) string {444 gap := w - lipgloss.Width(left) - lipgloss.Width(right)445 if gap < 1 {446 return ansi.Truncate(left, max(w-lipgloss.Width(right)-1, 0), "…") + " " + right447 }448 return left + strings.Repeat(" ", gap) + right449}450451func ago(now, t time.Time) string {452 if t.IsZero() {453 return "never"454 }455 d := now.Sub(t)456 switch {457 case d < 2*time.Second:458 return "just now"459 case d < time.Minute:460 return fmt.Sprintf("%ds ago", int(d.Seconds()))461 case d < time.Hour:462 return fmt.Sprintf("%dm ago", int(d.Minutes()))463 case d < 48*time.Hour:464 return fmt.Sprintf("%dh ago", int(d.Hours()))465 default:466 return t.Format("2006-01-02")467 }468}469470func tildePath(p string) string {471 if home, err := os.UserHomeDir(); err == nil && home != "" {472 if rel, err := filepath.Rel(home, p); err == nil && !strings.HasPrefix(rel, "..") {473 return filepath.Join("~", rel)474 }475 }476 return p477}