@owdproject/cli 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/go/tui/view.go ADDED
@@ -0,0 +1,812 @@
1
+ package tui
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+
7
+ "owd-cli/bridge"
8
+
9
+ "github.com/charmbracelet/lipgloss"
10
+ )
11
+
12
+ // ─────────────────────────────────────────────
13
+ // View structural layout
14
+ // ─────────────────────────────────────────────
15
+
16
+ func (m *TuiModel) View() string {
17
+ w := m.termWidth
18
+ h := m.termHeight
19
+ if w < 40 {
20
+ w = 40
21
+ }
22
+ if h < 11 {
23
+ h = 11
24
+ }
25
+ // h = h - 1 // Safety margin of 1 line at the bottom to prevent scrolling
26
+
27
+ showRightPanel := w >= 120
28
+
29
+ // Heights
30
+ topH := 9 // top panels row (with borders = 9 rows)
31
+ barH := 4 // status bar: 1 blank line + line1 + line2 = 4 rows
32
+ // catalog gets the remaining height
33
+ catalogH := h - topH - barH
34
+ if catalogH < 4 {
35
+ catalogH = 4
36
+ }
37
+
38
+ // Column widths (including borders each panel takes +2 cols)
39
+ var leftW, midW, rightW int
40
+ if showRightPanel {
41
+ // 40% | 28% | 32% — roughly matching original blessed layout
42
+ rightW = w * 32 / 100
43
+ leftW = w * 40 / 100
44
+ midW = w - leftW - rightW
45
+ } else {
46
+ leftW = w * 58 / 100
47
+ midW = w - leftW
48
+ rightW = 0
49
+ }
50
+
51
+ // ── Render panel contents ─────────────────────────────
52
+ leftContent := m.renderClientPanel(leftW-4, topH-2)
53
+ midContent := m.renderMetricsPanel(midW-4, topH-2)
54
+
55
+ desktopTitle := accentStyle.Render("Desktop") + " " +
56
+ desktopSubTitleStyle.Render("control panel")
57
+
58
+ var topRow, mainContent, catalogPanel string
59
+
60
+ if showRightPanel {
61
+ catW := leftW + midW
62
+ var rightPanel string
63
+ logContent := m.renderLogsPanel(rightW-4, h-barH-2)
64
+ baseTitle := "Logs"
65
+ if m.activePrompt == PromptResolveDependency {
66
+ baseTitle = "Wizard Setup"
67
+ } else if m.activeTask == TaskSetup || m.activeTask == TaskWipe {
68
+ baseTitle = "Setup"
69
+ }
70
+ baseStyle := lipgloss.NewStyle().Foreground(colorWhite).Bold(true)
71
+ styledBase := baseStyle.Render(baseTitle)
72
+ clearBtn := lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render("[clear]")
73
+ rightPanelTitle := styledBase + " " + clearBtn
74
+
75
+ rightPanel = drawPanel(rightW, h-barH, rightPanelTitle, logContent, false)
76
+
77
+ // Top 2-panel row (left + mid)
78
+ topTwoPanels := lipgloss.JoinHorizontal(lipgloss.Top,
79
+ drawPanel(leftW-1, topH, desktopTitle, leftContent, false),
80
+ drawPanel(midW, topH, "Metrics", midContent, false),
81
+ )
82
+
83
+ // Catalog panel — always rendered; modal overlaid on top if active
84
+ catalogContent := m.renderCatalogPanel(catW-4, catalogH-2, showRightPanel)
85
+ catalogPanel = drawPanel(catW-1, catalogH, "Catalog", catalogContent, true)
86
+ promptToShow := m.activePrompt
87
+ if (m.activeTask == TaskSetup || m.activeTask == TaskWipe) &&
88
+ m.activePrompt != PromptResolveDependency &&
89
+ (promptToShow == PromptNone || promptToShow == PromptUninstallConfirm || promptToShow == PromptInstallMethod || promptToShow == PromptWipeWorkspaceConfirm) {
90
+ promptToShow = PromptSetupProgress
91
+ }
92
+ if promptToShow != PromptNone {
93
+ modal := m.renderModal(promptToShow)
94
+ styledModal := modalWrapperStyle.Render(modal)
95
+ catalogPanel = overlayCenter(catalogPanel, styledModal)
96
+ }
97
+
98
+ // Left main block = top panels + catalog stacked
99
+ leftMain := lipgloss.JoinVertical(lipgloss.Left, topTwoPanels, catalogPanel)
100
+
101
+ topRow = lipgloss.JoinHorizontal(lipgloss.Top, leftMain, rightPanel)
102
+ mainContent = topRow
103
+ } else {
104
+ // No logs: top row = left + right panels side by side
105
+ topBlock := lipgloss.JoinHorizontal(lipgloss.Top,
106
+ drawPanel(leftW-1, topH, desktopTitle, leftContent, false),
107
+ drawPanel(midW, topH, "Metrics", midContent, false),
108
+ )
109
+
110
+ // Catalog full width — always rendered; modal overlaid on top if active
111
+ catalogContent := m.renderCatalogPanel(w-4, catalogH-2, showRightPanel)
112
+ catalogPanel = drawPanel(w, catalogH, "Catalog", catalogContent, true)
113
+ promptToShow := m.activePrompt
114
+ if (m.activeTask == TaskSetup || m.activeTask == TaskWipe) &&
115
+ m.activePrompt != PromptResolveDependency &&
116
+ (promptToShow == PromptNone || promptToShow == PromptUninstallConfirm || promptToShow == PromptInstallMethod || promptToShow == PromptWipeWorkspaceConfirm) {
117
+ promptToShow = PromptSetupProgress
118
+ }
119
+ if promptToShow != PromptNone {
120
+ modal := m.renderModal(promptToShow)
121
+ styledModal := modalWrapperStyle.Render(modal)
122
+ catalogPanel = overlayCenter(catalogPanel, styledModal)
123
+ }
124
+
125
+ mainContent = lipgloss.JoinVertical(lipgloss.Left, topBlock, catalogPanel)
126
+ }
127
+
128
+ // ── Status bar (borderless) ───────────────────────────
129
+ statusBar := m.renderStatusBar(w)
130
+
131
+ out := mainContent + statusBar
132
+
133
+ // Hard clamp: ensure the final output never exceeds termHeight lines.
134
+ // This is the last line of defence against terminal scrolling.
135
+ outLines := strings.Split(out, "\n")
136
+ if len(outLines) > m.termHeight {
137
+ out = strings.Join(outLines[:m.termHeight], "\n")
138
+ }
139
+
140
+ return out
141
+ }
142
+
143
+ // ─────────────────────────────────────────────
144
+ // Panel: Desktop Control Panel (left)
145
+ // ─────────────────────────────────────────────
146
+
147
+ func (m *TuiModel) renderClientPanel(w, h int) string {
148
+ var lines []string
149
+
150
+ // Server status line
151
+ var statusLine string
152
+ if m.activeTask == TaskServe && strings.Contains(strings.ToLower(m.statusMsg), "stopping") {
153
+ spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
154
+ frame := spinnerFrames[m.tickCount%len(spinnerFrames)]
155
+ dot := warnBoldStyle.Render(frame)
156
+ statusLine = dot + " " + boldStyle.Render("STOPPING") + " " + mutedStyle.Render("http://localhost:3000")
157
+ } else if m.activeTask == TaskServe && strings.Contains(strings.ToLower(m.statusMsg), "starting") {
158
+ spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
159
+ frame := spinnerFrames[m.tickCount%len(spinnerFrames)]
160
+ dot := cyanBoldStyle.Render(frame)
161
+ statusLine = dot + " " + boldStyle.Render("STARTING") + " " + mutedStyle.Render("http://localhost:3000")
162
+ } else if m.serverRunning {
163
+ dot := accentStyle.Render("●")
164
+ if m.blink {
165
+ dot = blinkGreenStyle.Render("●")
166
+ }
167
+ url := cyanRegularStyle.Render("http://localhost:3000")
168
+ statusLine = dot + " " + boldStyle.Render("RUNNING") + " " + url
169
+ if m.ctx != nil {
170
+ _ = m.ctx
171
+ }
172
+ } else if m.activeTask != TaskNone {
173
+ spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
174
+ frame := spinnerFrames[m.tickCount%len(spinnerFrames)]
175
+ dot := warnBoldStyle.Render(frame)
176
+ statusLine = dot + " " + boldStyle.Render("STARTING") + " " + mutedStyle.Render("http://localhost:3000")
177
+ } else {
178
+ dot := errStyle.Render("○")
179
+ statusLine = dot + " " + boldStyle.Render("STOPPED") + " " + mutedStyle.Render("http://localhost:3000")
180
+ }
181
+ lines = append(lines, " "+statusLine)
182
+ lines = append(lines, "") // Space out server status line
183
+ lines = append(lines, mutedStyle.Render(" System ")+boldStyle.Render("ready"))
184
+
185
+ // Theme
186
+ theme := "—"
187
+ if m.ctx != nil && m.ctx.Config.Theme != nil && *m.ctx.Config.Theme != "" {
188
+ t := *m.ctx.Config.Theme
189
+ if idx := strings.LastIndex(t, "/"); idx >= 0 {
190
+ t = t[idx+1:]
191
+ }
192
+ theme = t
193
+ }
194
+ if m.pendingTheme != nil {
195
+ t := *m.pendingTheme
196
+ if idx := strings.LastIndex(t, "/"); idx >= 0 {
197
+ t = t[idx+1:]
198
+ }
199
+ theme = t + warnStyle.Render(" (pending)")
200
+ }
201
+ lines = append(lines, mutedStyle.Render(" Theme ")+boldStyle.Render(theme))
202
+
203
+ // Source
204
+ source := "—"
205
+ if m.ctx != nil {
206
+ if m.ctx.Paths.IsPlayground {
207
+ source = "playground"
208
+ } else {
209
+ source = "monorepo"
210
+ }
211
+ }
212
+ lines = append(lines, mutedStyle.Render(" Source ")+boldStyle.Render(source))
213
+
214
+ // Git Branch
215
+ lines = append(lines, mutedStyle.Render(" Git Branch ")+boldStyle.Render(m.workspaceBranch))
216
+
217
+ // Workspace Changes
218
+ lines = append(lines, mutedStyle.Render(" Changes ")+m.workspaceChanges)
219
+
220
+ return strings.Join(lines, "\n")
221
+ }
222
+
223
+ // ─────────────────────────────────────────────
224
+ // Panel: Metrics (right of control panel)
225
+ // ─────────────────────────────────────────────
226
+
227
+ func (m *TuiModel) renderMetricsPanel(w, h int) string {
228
+ var lines []string
229
+
230
+ // Memory sparkline
231
+ spark := sparkline(m.memHistory)
232
+ memVal := "—"
233
+ if len(m.memHistory) > 0 && m.memHistory[len(m.memHistory)-1] > 0 {
234
+ memVal = fmt.Sprintf("%d MiB", m.memHistory[len(m.memHistory)-1])
235
+ }
236
+
237
+ lines = append(lines, kv("Memory", memVal)+" "+spark)
238
+
239
+ // Local counts
240
+ appCount := countLocalDirs(m.workspaceRoot, "apps")
241
+ pkgCount := countLocalDirs(m.workspaceRoot, "packages", "core", "cli", "nx")
242
+ themeCount := countLocalDirs(m.workspaceRoot, "themes")
243
+
244
+ lines = append(lines, kv("Local Apps", fmt.Sprintf("%d", appCount)))
245
+ lines = append(lines, kv("Packages", fmt.Sprintf("%d", pkgCount)))
246
+ lines = append(lines, kv("Themes", fmt.Sprintf("%d", themeCount)))
247
+
248
+ // Nuxt & Core Versions
249
+ nuxtRoot := m.workspaceRoot
250
+ if m.ctx != nil && m.ctx.Paths.IsPlayground {
251
+ nuxtRoot = m.ctx.Paths.PackageDir
252
+ }
253
+ vers := getVersions(nuxtRoot, m.workspaceRoot)
254
+ lines = append(lines, kv("Nuxt", vers.Nuxt)+mutedStyle.Render(" · ")+kv("Core", vers.Owd))
255
+
256
+ // Tips rotating every 5 seconds (approx. 33 ticks of 150ms)
257
+ var tips []string
258
+ if m.clientStars >= 0 {
259
+ tips = []string{
260
+ fmt.Sprintf("★ owdproject/client: %d stars", m.clientStars),
261
+ "Tip: Use arrow keys to navigate",
262
+ "Tip: Space to toggle a package",
263
+ "Tip: 'r' to refresh the catalog",
264
+ "Tip: 's' to start/stop server",
265
+ "Tip: 'c' to manage a package",
266
+ "Tip: 'n' to scaffold new package",
267
+ }
268
+ } else {
269
+ tips = []string{
270
+ "Tip: Use arrow keys to navigate",
271
+ "Tip: Space to toggle a package",
272
+ "Tip: 'r' to refresh the catalog",
273
+ "Tip: 's' to start/stop server",
274
+ "Tip: 'c' to manage a package",
275
+ "Tip: 'n' to scaffold new package",
276
+ }
277
+ }
278
+ tipIdx := (m.tickCount / 33) % len(tips)
279
+ tipText := tips[tipIdx]
280
+
281
+ lines = append(lines, "")
282
+ lines = append(lines, dimStyle.Render(truncate(tipText, w-2)))
283
+
284
+ for i, line := range lines {
285
+ lines[i] = " " + line
286
+ }
287
+
288
+ return strings.Join(lines, "\n")
289
+ }
290
+
291
+ // ─────────────────────────────────────────────
292
+ // Panel: Catalog
293
+ // ─────────────────────────────────────────────
294
+
295
+ func (m *TuiModel) renderCatalogPanel(w, h int, _ bool) string {
296
+ var lines []string
297
+
298
+ // Tabs row
299
+ var tabLine strings.Builder
300
+ tabLine.WriteString(" ")
301
+ if m.activeTab == 0 {
302
+ tabLine.WriteString(tabActiveStyle.Render("Internal"))
303
+ } else {
304
+ tabLine.WriteString(tabInactiveStyle.Render("Internal"))
305
+ }
306
+ tabLine.WriteString(" ")
307
+ if m.activeTab == 1 {
308
+ tabLine.WriteString(tabActiveStyle.Render("Apps"))
309
+ } else {
310
+ tabLine.WriteString(tabInactiveStyle.Render("Apps"))
311
+ }
312
+ tabLine.WriteString(" ")
313
+ if m.activeTab == 2 {
314
+ tabLine.WriteString(tabActiveStyle.Render("Modules"))
315
+ } else {
316
+ tabLine.WriteString(tabInactiveStyle.Render("Modules"))
317
+ }
318
+ tabLine.WriteString(" ")
319
+ if m.activeTab == 3 {
320
+ tabLine.WriteString(tabActiveStyle.Render("Themes"))
321
+ } else {
322
+ tabLine.WriteString(tabInactiveStyle.Render("Themes"))
323
+ }
324
+ lines = append(lines, tabLine.String())
325
+ lines = append(lines, "")
326
+
327
+ items := m.getActiveItems()
328
+
329
+ if len(items) == 0 {
330
+ lines = append(lines, " "+mutedStyle.Render("No packages available in this tab"))
331
+ return strings.Join(lines, "\n")
332
+ }
333
+
334
+ // Calculate pagination heights
335
+ visibleRows := h - 6 // height minus tabs, spaces, detail pane separator and details row
336
+ if visibleRows < 1 {
337
+ visibleRows = 1
338
+ }
339
+
340
+ // Apply scrolling offsets
341
+ if m.selectedIndex < m.scrollOffset {
342
+ m.scrollOffset = m.selectedIndex
343
+ } else if m.selectedIndex >= m.scrollOffset+visibleRows {
344
+ m.scrollOffset = m.selectedIndex - visibleRows + 1
345
+ }
346
+
347
+ // Table header
348
+ headerStyle := tableHeaderStyle
349
+ colNameW := w * 28 / 100
350
+ if colNameW < 12 {
351
+ colNameW = 12
352
+ }
353
+ // Row prefix: " "(2) + badge(1) + " "(3) = 6 chars before the name text.
354
+ // Header must use the same 6-char prefix, NAME label shrunk by 4 to compensate.
355
+ header := " " +
356
+ padRight("NAME", colNameW-4) + " " +
357
+ padRight("VERSION", 9) + " " +
358
+ padRight("SRC", 5) + " " +
359
+ padRight("SYNC", 14) + " " +
360
+ padRight("PUBLISHER", 12) + " " +
361
+ padRight("★", 6) + " " +
362
+ "AGE"
363
+ lines = append(lines, headerStyle.Render(header))
364
+ lines = append(lines, headerStyle.Render(" "+strings.Repeat("─", w-4)))
365
+
366
+ // Render page rows
367
+ for i := 0; i < visibleRows; i++ {
368
+ idx := m.scrollOffset + i
369
+ if idx >= len(items) {
370
+ break
371
+ }
372
+ item := items[idx]
373
+ row := m.renderCatalogRow(item, idx == m.selectedIndex, w, colNameW)
374
+ lines = append(lines, row)
375
+ }
376
+
377
+ // Pad empty space to keep layout stable
378
+ for len(lines) < h-2 {
379
+ lines = append(lines, "")
380
+ }
381
+
382
+ // Details panel
383
+ lines = append(lines, headerStyle.Render(" "+strings.Repeat("─", w-4)))
384
+ lines = append(lines, m.renderDetailRow(items))
385
+
386
+ return strings.Join(lines, "\n")
387
+ }
388
+
389
+ func (m *TuiModel) renderCatalogRow(item bridge.CatalogEntry, selected bool, w, nameW int) string {
390
+ // ── Badge (checkbox / radiobox) ─────────────────────────────
391
+ var badge string
392
+ if item.Kind == "theme" {
393
+ if activeThemeName(m.ctx, m.pendingTheme) == item.Name {
394
+ badge = accentStyle.Render("◉")
395
+ } else {
396
+ badge = subtleStyle.Render("○")
397
+ }
398
+ } else {
399
+ active := item.Installed
400
+ if val, exists := m.pendingPackages[item.Name]; exists {
401
+ active = val
402
+ }
403
+ if active {
404
+ badge = accentStyle.Render("☒")
405
+ } else {
406
+ badge = subtleStyle.Render("☐")
407
+ }
408
+ }
409
+
410
+ shortName := item.ShortName
411
+ name := badge + " " + shortName
412
+
413
+ // ── VERSION ─────────────────────────────────────────────────
414
+ version := "—"
415
+ if item.Version != nil {
416
+ version = *item.Version
417
+ }
418
+
419
+ // ── SRC (npm / git / dev) ────────────────────────────────────
420
+ source := catalogSourceLabel(item, m.localGitDirs)
421
+
422
+ // ── SYNC column: git changes + update indicators ─────────────
423
+ var syncParts []string
424
+ if gitStat, ok := m.gitChangesMap[item.ShortName]; ok {
425
+ if gitStat.Added > 0 {
426
+ syncParts = append(syncParts, accentStyle.Render(fmt.Sprintf("+%d", gitStat.Added)))
427
+ }
428
+ if gitStat.Modified > 0 {
429
+ syncParts = append(syncParts, warnStyle.Render(fmt.Sprintf("~%d", gitStat.Modified)))
430
+ }
431
+ if gitStat.Deleted > 0 {
432
+ syncParts = append(syncParts, errStyle.Render(fmt.Sprintf("-%d", gitStat.Deleted)))
433
+ }
434
+ }
435
+ if upInfo, ok := m.updatesMap[item.ShortName]; ok {
436
+ if upInfo.LocalGit && upInfo.BehindCount > 0 {
437
+ syncParts = append(syncParts, cyanStyle.Render(fmt.Sprintf("↓%d", upInfo.BehindCount)))
438
+ } else if upInfo.Npm {
439
+ syncParts = append(syncParts, accentStyle.Render("↑"))
440
+ }
441
+ }
442
+ sync := subtleStyle.Render("—")
443
+ if len(syncParts) > 0 {
444
+ sync = strings.Join(syncParts, " ")
445
+ }
446
+
447
+ // ── PUBLISHER ────────────────────────────────────────────────
448
+ pub := item.Org
449
+ if pub == "" || pub == "workspace" {
450
+ pub = "owdproject"
451
+ }
452
+ publisher := truncate(pub, 12)
453
+
454
+ // ── STARS ────────────────────────────────────────────────────
455
+ stars := subtleStyle.Render("—")
456
+ if item.Stars > 0 {
457
+ stars = warnStyle.Render(fmt.Sprintf("★ %d", item.Stars))
458
+ }
459
+
460
+ // ── AGE (last push) ──────────────────────────────────────────
461
+ age := subtleStyle.Render("—")
462
+ if item.PushedAt != nil {
463
+ age = mutedStyle.Render(formatCatalogAge(item.PushedAt))
464
+ } else if item.UpdatedAt != nil {
465
+ age = mutedStyle.Render(formatCatalogAge(item.UpdatedAt))
466
+ }
467
+
468
+ // ── Assemble row ─────────────────────────────────────────────
469
+ var nameCol string
470
+ if selected {
471
+ nameCol = selectedRowStyle.Render(padRight(name, nameW))
472
+ } else {
473
+ nameCol = padRight(name, nameW)
474
+ }
475
+
476
+ row := " " + nameCol + " " +
477
+ padRight(version, 9) + " " +
478
+ padRight(source, 5) + " " +
479
+ padRight(sync, 14) + " " +
480
+ padRight(publisher, 12) + " " +
481
+ padRight(stars, 6) + " " +
482
+ age
483
+
484
+ if selected {
485
+ return selectedRowBgStyle.Render(row)
486
+ }
487
+ return row
488
+ }
489
+
490
+ func (m *TuiModel) renderDetailRow(items []bridge.CatalogEntry) string {
491
+ if len(items) == 0 || m.selectedIndex >= len(items) {
492
+ return " " + mutedStyle.Render("Select a package for install preview")
493
+ }
494
+
495
+ item := items[m.selectedIndex]
496
+
497
+ org := "—"
498
+ if item.Org != "" {
499
+ org = item.Org
500
+ }
501
+ stars := "—"
502
+ if item.Stars > 0 {
503
+ stars = fmt.Sprintf("★ %d", item.Stars)
504
+ }
505
+
506
+ var status string
507
+ if item.Installed {
508
+ status = accentStyle.Render("Installed")
509
+ if item.LocalSource {
510
+ status += " " + subtleStyle.Render("(Local symlink)")
511
+ } else {
512
+ status += " " + subtleStyle.Render("(NPM dependency)")
513
+ }
514
+ } else {
515
+ status = subtleStyle.Render("Not installed")
516
+ }
517
+
518
+ kind := strings.Title(item.Kind)
519
+
520
+ parts := []string{
521
+ accentStyle.Render(item.ShortName),
522
+ " " + mutedStyle.Render("Publisher:") + " " + boldStyle.Render(org),
523
+ " " + mutedStyle.Render("Stars:") + " " + boldStyle.Render(stars),
524
+ " " + mutedStyle.Render("Status:") + " " + status,
525
+ " " + mutedStyle.Render("Type:") + " " + boldStyle.Render(kind),
526
+ }
527
+
528
+ return " " + strings.Join(parts, " ")
529
+ }
530
+
531
+ // ─────────────────────────────────────────────
532
+ // Panel: Logs (right column, when server running)
533
+ // ─────────────────────────────────────────────
534
+
535
+ func (m *TuiModel) isServerCmdNil() bool {
536
+ m.runtime.serverMu.Lock()
537
+ defer m.runtime.serverMu.Unlock()
538
+ return m.runtime.serverCmd == nil
539
+ }
540
+
541
+ func (m *TuiModel) renderLogsPanel(w, h int) string {
542
+ var lines []string
543
+ lines = append(lines, "")
544
+
545
+ // Work queue panel — shown during wizard engine
546
+ if m.workQueue != nil && m.activeTask == TaskSetup && len(m.workQueue.Items) > 0 {
547
+ completed, total := m.workQueue.Progress()
548
+ lines = append(lines, " "+boldStyle.Render(fmt.Sprintf("Work Queue (%d/%d)", completed, total)))
549
+ spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
550
+ spin := cyanBoldStyle.Render(spinnerFrames[m.tickCount%len(spinnerFrames)])
551
+ for _, item := range m.workQueue.Items {
552
+ icon := item.StatusIcon()
553
+ label := mutedStyle.Render(item.ShortName)
554
+ status := string(item.Status)
555
+ if item.Status == StatusRunning {
556
+ lines = append(lines, " "+spin+" "+icon+" "+label+" "+accentStyle.Render(status))
557
+ } else {
558
+ lines = append(lines, " "+icon+" "+label+" "+mutedStyle.Render(status))
559
+ }
560
+ }
561
+ if m.enginePhase != PhaseIdle && m.enginePhase != "" {
562
+ lines = append(lines, " "+mutedStyle.Render("Phase: "+string(m.enginePhase)))
563
+ }
564
+ lines = append(lines, "")
565
+ }
566
+
567
+ // Setup progress header — shown during active setup/wipe tasks
568
+ if (m.activeTask == TaskSetup || m.activeTask == TaskWipe) && m.setupTotalSteps > 0 {
569
+ // Progress bar
570
+ barWidth := w - 6
571
+ if barWidth < 4 {
572
+ barWidth = 4
573
+ }
574
+ filled := 0
575
+ if m.setupTotalSteps > 0 {
576
+ filled = (m.setupStep * barWidth) / m.setupTotalSteps
577
+ }
578
+ if filled > barWidth {
579
+ filled = barWidth
580
+ }
581
+ progBar := "[" + strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) + "]"
582
+ progPct := 0
583
+ if m.setupTotalSteps > 0 {
584
+ progPct = (m.setupStep * 100) / m.setupTotalSteps
585
+ }
586
+ lines = append(lines, " "+accentStyle.Render(fmt.Sprintf("%s %d%% (%d/%d)", progBar, progPct, m.setupStep, m.setupTotalSteps)))
587
+ lines = append(lines, " "+mutedStyle.Render(m.setupLabel))
588
+ lines = append(lines, "")
589
+ }
590
+
591
+ // Server URLs
592
+ if m.serverRunning {
593
+ dot := accentStyle.Render("●")
594
+ if !m.blink {
595
+ dot = statusGreenStyle.Render("●")
596
+ }
597
+
598
+ lines = append(lines, " "+dot+" "+accentStyle.Render("Dev server started"))
599
+ lines = append(lines, "")
600
+ lines = append(lines, " "+accentStyle.Render("✓")+" "+mutedStyle.Render("Listening on http://localhost:3000"))
601
+ lines = append(lines, "")
602
+ }
603
+
604
+ // Log lines — show last N that fit
605
+ logH := h - len(lines) - 1
606
+ if logH < 1 {
607
+ logH = 1
608
+ }
609
+ logStart := len(m.logLines) - logH
610
+ if logStart < 0 {
611
+ logStart = 0
612
+ }
613
+ for i := logStart; i < len(m.logLines); i++ {
614
+ line := m.logLines[i]
615
+ line = truncate(line, w)
616
+ // Colorize log levels
617
+ switch {
618
+ case strings.Contains(line, "WARN") || strings.Contains(line, "Warning"):
619
+ lines = append(lines, warnStyle.Render(" "+line))
620
+ case strings.Contains(line, "ERROR") || strings.Contains(line, "Error") || strings.Contains(line, "failed") || strings.HasPrefix(line, "✗"):
621
+ lines = append(lines, errStyle.Render(" "+line))
622
+ case strings.HasPrefix(line, "⚙"):
623
+ lines = append(lines, cyanRegularStyle.Render(" "+line))
624
+ case strings.HasPrefix(line, "ℹ"):
625
+ lines = append(lines, accentRegularStyle.Render(" "+line))
626
+ case strings.HasPrefix(line, "✓") || strings.Contains(line, "ready") || strings.Contains(line, "started"):
627
+ lines = append(lines, accentRegularStyle.Render(" "+line))
628
+ default:
629
+ lines = append(lines, mutedStyle.Render(" "+line))
630
+ }
631
+ }
632
+
633
+ if len(m.logLines) == 0 {
634
+ if m.serverRunning && m.isServerCmdNil() {
635
+ lines = append(lines, mutedStyle.Render(" Logs unavailable (external server)"))
636
+ } else if m.activeTask != TaskNone {
637
+ // Spinner while waiting for first log line during an active task
638
+ spinners := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
639
+ spin := spinners[m.tickCount%len(spinners)]
640
+ lines = append(lines, mutedStyle.Render(" "+spin+" Running…"))
641
+ } else if m.serverRunning {
642
+ lines = append(lines, mutedStyle.Render(" Waiting for logs…"))
643
+ }
644
+ }
645
+
646
+ return strings.Join(lines, "\n")
647
+ }
648
+
649
+ func (m *TuiModel) renderInitializingPanel(w, h int) string {
650
+ var lines []string
651
+
652
+ lines = append(lines, "")
653
+
654
+ // Vertical spacing to center content roughly
655
+ paddingTop := (h - 6) / 2
656
+ if paddingTop < 1 {
657
+ paddingTop = 1
658
+ }
659
+ for i := 0; i < paddingTop; i++ {
660
+ lines = append(lines, "")
661
+ }
662
+
663
+ // Spinner frames (braille pattern makes a beautiful spinner)
664
+ spinnerFrames := []string{
665
+ "⢹", "⢺", "⢽", "⢾", "⡿", "⢿", "⣻", "⣽", "⣾", "⣿",
666
+ "⣇", "⣆", "⣃", "⣅", "⣄", "⣠", "⣡", "⣢", "⣣", "⣤",
667
+ }
668
+ frame := spinnerFrames[m.tickCount%len(spinnerFrames)]
669
+ spin := spinnerStyle.Render(frame)
670
+
671
+ lines = append(lines, " "+spin+" "+accentStyle.Render("Initializing OWD…"))
672
+ lines = append(lines, "")
673
+
674
+ taskLabel := m.statusMsg
675
+ if m.setupLabel != "" {
676
+ taskLabel = m.setupLabel
677
+ }
678
+ // Trim/truncate label to fit
679
+ taskLabel = truncate(taskLabel, w-6)
680
+ lines = append(lines, " "+boldStyle.Render(taskLabel))
681
+
682
+ if m.setupTotalSteps > 0 {
683
+ lines = append(lines, "")
684
+ bar := renderProgressBar(m.setupStep, m.setupTotalSteps, w-10)
685
+ lines = append(lines, " "+bar)
686
+ lines = append(lines, fmt.Sprintf(" Step %d of %d", m.setupStep, m.setupTotalSteps))
687
+ }
688
+
689
+ // Pad the rest of the lines
690
+ for len(lines) < h {
691
+ lines = append(lines, "")
692
+ }
693
+
694
+ return strings.Join(lines, "\n")
695
+ }
696
+
697
+ // ─────────────────────────────────────────────
698
+ // Status Bar (borderless, Gemini-style)
699
+ // ─────────────────────────────────────────────
700
+
701
+ func (m *TuiModel) renderStatusBar(w int) string {
702
+ mode := ""
703
+ switch m.activeTab {
704
+ case 0:
705
+ mode = "Apps"
706
+ case 1:
707
+ mode = "Modules"
708
+ case 2:
709
+ mode = "Themes"
710
+ }
711
+
712
+ statusIcon := ""
713
+ if m.serverRunning {
714
+ if m.blink {
715
+ statusIcon = accentRegularStyle.Render("● ")
716
+ } else {
717
+ statusIcon = statusGreenStyle.Render("● ")
718
+ }
719
+ }
720
+
721
+ var serverShortcut string
722
+ if m.serverRunning {
723
+ serverShortcut = barKeyStyle.Render("x") + barTextStyle.Render(" stop server")
724
+ } else {
725
+ serverShortcut = barKeyStyle.Render("d") + barTextStyle.Render(" start server")
726
+ }
727
+
728
+ line1Parts := []string{
729
+ statusIcon + barKeyStyle.Render("Select packages") + barTextStyle.Render(" · "),
730
+ }
731
+ if m.hasPendingChanges() {
732
+ line1Parts = append(line1Parts, barKeyStyle.Render("s")+barTextStyle.Render(" save changes · "))
733
+ }
734
+ line1Parts = append(line1Parts,
735
+ serverShortcut+barTextStyle.Render(" · "),
736
+ barKeyStyle.Render("g")+barTextStyle.Render(" settings"),
737
+ )
738
+
739
+ if m.activeTask != TaskNone {
740
+ spinFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
741
+ spin := cyanRegularStyle.Render(spinFrames[m.tickCount%len(spinFrames)])
742
+ line1Parts = []string{spin + " " + barTextStyle.Render(m.statusMsg)}
743
+ }
744
+
745
+ _ = mode
746
+
747
+ // Render line1 and line2 WITHOUT Width() to prevent wrapping.
748
+ // Then truncate to exactly w columns using lipgloss-aware truncation.
749
+ line1Raw := barStyle.PaddingLeft(2).Render(strings.Join(line1Parts, ""))
750
+ if lipgloss.Width(line1Raw) > w {
751
+ line1Raw = truncate(line1Raw, w)
752
+ }
753
+
754
+ // Line 2: shortcuts
755
+ sep := barSepStyle.Render(" │ ")
756
+ var shortcutParts []string
757
+ shortcutParts = append(shortcutParts,
758
+ barKeyStyle.Render("↑↓")+barTextStyle.Render(" move"),
759
+ barKeyStyle.Render("Space")+barTextStyle.Render(" toggle"),
760
+ barKeyStyle.Render("c")+barTextStyle.Render(" manage"),
761
+ )
762
+
763
+ items := m.getActiveItems()
764
+ hasHoveredInstalled := false
765
+ if len(items) > 0 && m.selectedIndex < len(items) {
766
+ hasHoveredInstalled = items[m.selectedIndex].Installed
767
+ }
768
+ if hasHoveredInstalled {
769
+ shortcutParts = append(shortcutParts, barKeyStyle.Render("u")+barTextStyle.Render(" update"))
770
+ }
771
+
772
+ shortcutParts = append(shortcutParts,
773
+ barKeyStyle.Render("r")+barTextStyle.Render(" refresh"),
774
+ barKeyStyle.Render("n")+barTextStyle.Render(" new"),
775
+ barKeyStyle.Render("q")+barTextStyle.Render(" quit"),
776
+ )
777
+ line2Raw := barStyle.PaddingLeft(2).Render(strings.Join(shortcutParts, sep))
778
+ if lipgloss.Width(line2Raw) > w {
779
+ line2Raw = truncate(line2Raw, w)
780
+ }
781
+
782
+ // Always return exactly 4 lines: 1 blank + line1 + line2
783
+ return "\n\n" + line1Raw + "\n" + line2Raw
784
+ }
785
+
786
+ func (m *TuiModel) getActiveItems() []bridge.CatalogEntry {
787
+ if m.catalog == nil {
788
+ return nil
789
+ }
790
+ var res []bridge.CatalogEntry
791
+ for _, e := range m.catalog.Entries {
792
+ switch m.activeTab {
793
+ case 0: // Internal
794
+ if e.Kind == "module" && !strings.HasPrefix(e.ShortName, "module-") {
795
+ res = append(res, e)
796
+ }
797
+ case 1: // Apps
798
+ if e.Kind == "app" {
799
+ res = append(res, e)
800
+ }
801
+ case 2: // Modules
802
+ if e.Kind == "module" && strings.HasPrefix(e.ShortName, "module-") {
803
+ res = append(res, e)
804
+ }
805
+ case 3: // Themes
806
+ if e.Kind == "theme" {
807
+ res = append(res, e)
808
+ }
809
+ }
810
+ }
811
+ return res
812
+ }