@owdproject/cli 0.0.3 → 0.1.0

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/tui.go ADDED
@@ -0,0 +1,3014 @@
1
+ package tui
2
+
3
+ import (
4
+ "bufio"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "math/rand"
10
+ "net/http"
11
+ "net/url"
12
+ "os"
13
+ "os/exec"
14
+ "path/filepath"
15
+ "runtime"
16
+ "strings"
17
+ "syscall"
18
+ "time"
19
+
20
+ "owd-cli/bridge"
21
+
22
+ tea "github.com/charmbracelet/bubbletea"
23
+ "github.com/charmbracelet/lipgloss"
24
+ )
25
+
26
+ // ─────────────────────────────────────────────
27
+ // Colors & Styles
28
+ // ─────────────────────────────────────────────
29
+
30
+ var (
31
+ colorAccent = lipgloss.Color("#7ee787")
32
+ colorCyan = lipgloss.Color("#24ebff")
33
+ colorDim = lipgloss.Color("#3a3a4a")
34
+ colorMuted = lipgloss.Color("#6c7086")
35
+ colorWarn = lipgloss.Color("#f9e2af")
36
+ colorErr = lipgloss.Color("#f38ba8")
37
+ colorNpm = lipgloss.Color("#7ee787")
38
+ colorGit = lipgloss.Color("#89dceb")
39
+ colorLocal = lipgloss.Color("#ff79c6")
40
+ colorBorder = lipgloss.Color("#4a4a6a")
41
+ colorBorderAct = lipgloss.Color("#7ee787")
42
+ colorWhite = lipgloss.Color("#cdd6f4")
43
+ colorSubtle = lipgloss.Color("#585b70")
44
+ colorBarBg = lipgloss.Color("#11111b")
45
+ colorBarFg = lipgloss.Color("#cdd6f4")
46
+ )
47
+
48
+ var (
49
+ panelBorderStyle = lipgloss.NewStyle().
50
+ Border(lipgloss.RoundedBorder()).
51
+ BorderForeground(colorBorder)
52
+
53
+ panelBorderActive = lipgloss.NewStyle().
54
+ Border(lipgloss.RoundedBorder()).
55
+ BorderForeground(colorBorderAct)
56
+
57
+ accentStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
58
+ cyanStyle = lipgloss.NewStyle().Foreground(colorCyan).Bold(true)
59
+ mutedStyle = lipgloss.NewStyle().Foreground(colorMuted)
60
+ warnStyle = lipgloss.NewStyle().Foreground(colorWarn)
61
+ errStyle = lipgloss.NewStyle().Foreground(colorErr)
62
+ boldStyle = lipgloss.NewStyle().Bold(true).Foreground(colorWhite)
63
+ dimStyle = lipgloss.NewStyle().Foreground(colorDim)
64
+ subtleStyle = lipgloss.NewStyle().Foreground(colorSubtle)
65
+
66
+ tabActiveStyle = lipgloss.NewStyle().
67
+ Bold(true).
68
+ Background(colorAccent).
69
+ Foreground(lipgloss.Color("#1e1e2e")).
70
+ Padding(0, 2)
71
+
72
+ tabInactiveStyle = lipgloss.NewStyle().
73
+ Foreground(colorMuted).
74
+ Padding(0, 2)
75
+
76
+ selectedRowStyle = lipgloss.NewStyle().
77
+ Bold(true).
78
+ Foreground(colorAccent)
79
+
80
+ installedBadge = lipgloss.NewStyle().Foreground(colorAccent).Render("●")
81
+ uninstalledBadge = lipgloss.NewStyle().Foreground(colorSubtle).Render("○")
82
+
83
+ // bottom bar — no border, gemini-style
84
+ barStyle = lipgloss.NewStyle().
85
+ Foreground(colorBarFg).
86
+ PaddingLeft(2)
87
+
88
+ barKeyStyle = lipgloss.NewStyle().
89
+ Foreground(colorAccent).
90
+ Bold(true)
91
+
92
+ barSepStyle = lipgloss.NewStyle().
93
+ Foreground(colorSubtle)
94
+
95
+ modalStyle = lipgloss.NewStyle().
96
+ Padding(1, 3)
97
+
98
+ modalOptionActive = lipgloss.NewStyle().
99
+ Background(colorCyan).
100
+ Foreground(lipgloss.Color("#000000")).
101
+ Bold(true).
102
+ Padding(0, 2)
103
+
104
+ modalOptionInactive = lipgloss.NewStyle().
105
+ Background(colorDim).
106
+ Foreground(colorMuted).
107
+ Padding(0, 2)
108
+ )
109
+
110
+ // ─────────────────────────────────────────────
111
+ // Msg types
112
+ // ─────────────────────────────────────────────
113
+
114
+ type contextLoadedMsg struct {
115
+ Ctx *bridge.WorkspaceContext
116
+ Err error
117
+ }
118
+
119
+ type catalogLoadedMsg struct {
120
+ Cat *bridge.CatalogResponse
121
+ Err error
122
+ }
123
+
124
+ type logLineMsg string
125
+ type clearLogsMsg struct{}
126
+
127
+ type taskFinishedMsg struct {
128
+ Success bool
129
+ Err error
130
+ }
131
+
132
+ type serverStatusMsg struct {
133
+ Running bool
134
+ }
135
+
136
+ type tickMsg time.Time
137
+
138
+ type memTickMsg struct {
139
+ MemMB int
140
+ }
141
+
142
+ type GitChanges struct {
143
+ Added int
144
+ Modified int
145
+ Deleted int
146
+ }
147
+
148
+ type UpdateInfo struct {
149
+ LocalGit bool
150
+ BehindCount int
151
+ Npm bool
152
+ }
153
+
154
+ type updatesLoadedMsg struct {
155
+ GitChanges map[string]GitChanges
156
+ Updates map[string]UpdateInfo
157
+ }
158
+
159
+ // ─────────────────────────────────────────────
160
+ // Prompt states
161
+ // ─────────────────────────────────────────────
162
+
163
+ type PromptType int
164
+
165
+ const (
166
+ PromptNone PromptType = iota
167
+ PromptInstallMethod
168
+ PromptUninstallConfirm
169
+ PromptManagePackage
170
+ PromptForceReinstallConfirm
171
+ PromptSettings
172
+ )
173
+
174
+ var Program *tea.Program
175
+ var ServerCmd *exec.Cmd
176
+
177
+ // ─────────────────────────────────────────────
178
+ // Model
179
+ // ─────────────────────────────────────────────
180
+
181
+ type TuiModel struct {
182
+ workspaceRoot string
183
+ ctx *bridge.WorkspaceContext
184
+ catalog *bridge.CatalogResponse
185
+
186
+ activeTab int // 0: Internal, 1: Apps, 2: Modules, 3: Themes
187
+ err error
188
+ loading bool
189
+ statusMsg string
190
+ logLines []string
191
+ memHistory []int
192
+
193
+ // Selection
194
+ selectedIndex int
195
+ scrollOffset int
196
+
197
+ // Prompts/Modals
198
+ activePrompt PromptType
199
+ promptPkg *bridge.CatalogEntry
200
+ promptSel int
201
+
202
+ promptPkgDeps []string
203
+ promptPkgHasPrimevue bool
204
+ promptPkgHasTailwind bool
205
+
206
+ gitChangesMap map[string]GitChanges
207
+ updatesMap map[string]UpdateInfo
208
+
209
+ settingsSel int
210
+ settingsInstallMode string
211
+ settingsCatalogSort string
212
+
213
+ // Server status
214
+ serverRunning bool
215
+ taskActive bool
216
+ checkingUpdates bool
217
+
218
+ // Animation
219
+ blink bool
220
+ tickCount int
221
+
222
+ // Layout
223
+ termWidth int
224
+ termHeight int
225
+
226
+ lastInstallMethod string
227
+ logTailerStarted bool
228
+ }
229
+
230
+ func NewModel(root string) TuiModel {
231
+ return TuiModel{
232
+ workspaceRoot: root,
233
+ activeTab: 1, // Default to Apps (index 1), Internal (index 0) is secret
234
+ loading: true,
235
+ statusMsg: "Initializing OWD Control Panel…",
236
+ logLines: []string{},
237
+ memHistory: []int{},
238
+ gitChangesMap: make(map[string]GitChanges),
239
+ updatesMap: make(map[string]UpdateInfo),
240
+ settingsSel: 0,
241
+ termWidth: 160,
242
+ termHeight: 40,
243
+ lastInstallMethod: "npm",
244
+ logTailerStarted: false,
245
+ }
246
+ }
247
+
248
+ func (m TuiModel) Init() tea.Cmd {
249
+ return tea.Batch(
250
+ m.loadContextCmd(),
251
+ m.loadCatalogCmd(false),
252
+ m.checkServerStatusCmd(),
253
+ tickCmd(),
254
+ )
255
+ }
256
+
257
+ // ─────────────────────────────────────────────
258
+ // Commands
259
+ // ─────────────────────────────────────────────
260
+
261
+ func tickCmd() tea.Cmd {
262
+ return tea.Tick(150*time.Millisecond, func(t time.Time) tea.Msg {
263
+ return tickMsg(t)
264
+ })
265
+ }
266
+
267
+ func memSampleCmd() tea.Cmd {
268
+ return func() tea.Msg {
269
+ var ms runtime.MemStats
270
+ runtime.ReadMemStats(&ms)
271
+ mb := int(ms.Sys / 1024 / 1024)
272
+ return memTickMsg{MemMB: mb}
273
+ }
274
+ }
275
+
276
+ func (m TuiModel) loadContextCmd() tea.Cmd {
277
+ return func() tea.Msg {
278
+ ctx, err := bridge.ReadWorkspaceContext(m.workspaceRoot)
279
+ return contextLoadedMsg{Ctx: ctx, Err: err}
280
+ }
281
+ }
282
+
283
+ func (m TuiModel) loadCatalogCmd(force bool) tea.Cmd {
284
+ return func() tea.Msg {
285
+ cat, err := bridge.ReadCatalog(m.workspaceRoot, force)
286
+ return catalogLoadedMsg{Cat: cat, Err: err}
287
+ }
288
+ }
289
+
290
+ func (m TuiModel) checkServerStatusCmd() tea.Cmd {
291
+ return func() tea.Msg {
292
+ if m.ctx == nil {
293
+ return serverStatusMsg{Running: false}
294
+ }
295
+
296
+ pidPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.pid")
297
+ data, err := os.ReadFile(pidPath)
298
+ if err != nil {
299
+ return serverStatusMsg{Running: false}
300
+ }
301
+
302
+ var pid int
303
+ if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &pid); err != nil {
304
+ return serverStatusMsg{Running: false}
305
+ }
306
+
307
+ process, err := os.FindProcess(pid)
308
+ if err != nil {
309
+ return serverStatusMsg{Running: false}
310
+ }
311
+
312
+ err = process.Signal(syscall.Signal(0))
313
+ return serverStatusMsg{Running: err == nil}
314
+ }
315
+ }
316
+
317
+ // ─────────────────────────────────────────────
318
+ // Update
319
+ // ─────────────────────────────────────────────
320
+
321
+ func (m TuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
322
+ switch msg := msg.(type) {
323
+
324
+ case tea.WindowSizeMsg:
325
+ m.termWidth = msg.Width
326
+ m.termHeight = msg.Height
327
+ return m, nil
328
+
329
+ case tickMsg:
330
+ m.tickCount++
331
+ if m.tickCount%4 == 0 {
332
+ m.blink = !m.blink
333
+ }
334
+
335
+ if m.tickCount%10 == 0 {
336
+ // Sample Nuxt process memory stats
337
+ var mb int
338
+ if m.ctx != nil {
339
+ pidPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.pid")
340
+ if data, err := os.ReadFile(pidPath); err == nil {
341
+ var pid int
342
+ if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &pid); err == nil {
343
+ mb = getProcessMemoryMB(pid)
344
+ }
345
+ }
346
+ }
347
+ m.memHistory = append(m.memHistory, mb)
348
+ if len(m.memHistory) > 30 {
349
+ m.memHistory = m.memHistory[1:]
350
+ }
351
+ }
352
+
353
+ if m.tickCount%100 == 0 && !m.loading && !m.taskActive && !m.checkingUpdates {
354
+ m.checkingUpdates = true
355
+ return m, tea.Batch(tickCmd(), m.checkForUpdatesCmd())
356
+ }
357
+
358
+ return m, tickCmd()
359
+
360
+ case tea.KeyMsg:
361
+ if m.activePrompt != PromptNone {
362
+ return m.handlePromptKeys(msg)
363
+ }
364
+ switch msg.String() {
365
+ case "q", "ctrl+c":
366
+ return m, tea.Quit
367
+ case "0":
368
+ m.activeTab, m.selectedIndex, m.scrollOffset = 0, 0, 0
369
+ case "1":
370
+ m.activeTab, m.selectedIndex, m.scrollOffset = 1, 0, 0
371
+ case "2":
372
+ m.activeTab, m.selectedIndex, m.scrollOffset = 2, 0, 0
373
+ case "3":
374
+ m.activeTab, m.selectedIndex, m.scrollOffset = 3, 0, 0
375
+ case "left":
376
+ if m.activeTab == 0 {
377
+ m.activeTab = 3
378
+ } else {
379
+ m.activeTab--
380
+ }
381
+ m.selectedIndex, m.scrollOffset = 0, 0
382
+ case "right":
383
+ if m.activeTab == 3 {
384
+ m.activeTab = 1
385
+ } else {
386
+ m.activeTab++
387
+ }
388
+ m.selectedIndex, m.scrollOffset = 0, 0
389
+ case "up", "k":
390
+ if m.selectedIndex > 0 {
391
+ m.selectedIndex--
392
+ if m.selectedIndex < m.scrollOffset {
393
+ m.scrollOffset = m.selectedIndex
394
+ }
395
+ }
396
+ case "down", "j":
397
+ items := m.getActiveItems()
398
+ if m.selectedIndex < len(items)-1 {
399
+ m.selectedIndex++
400
+ }
401
+ case "r":
402
+ m.loading = true
403
+ m.statusMsg = "Refreshing package catalog…"
404
+ return m, m.loadCatalogCmd(true)
405
+ case "s":
406
+ if !m.serverRunning && !m.taskActive {
407
+ m.statusMsg = "Starting dev server…"
408
+ m.taskActive = true
409
+ if Program != nil {
410
+ m.RunServeTask(Program)
411
+ }
412
+ }
413
+ case "x":
414
+ if m.serverRunning {
415
+ m.statusMsg = "Stopping dev server…"
416
+ m.taskActive = true
417
+ if Program != nil {
418
+ m.StopServeTask(Program)
419
+ }
420
+ }
421
+ case "c":
422
+ items := m.getActiveItems()
423
+ if len(items) > 0 && m.selectedIndex < len(items) {
424
+ pkg := items[m.selectedIndex]
425
+ m.promptPkg = &pkg
426
+ m.loadPackageDetails(&pkg)
427
+ m.activePrompt = PromptManagePackage
428
+ m.promptSel = 0
429
+ }
430
+ case "enter", " ":
431
+ items := m.getActiveItems()
432
+ if len(items) > 0 && m.selectedIndex < len(items) {
433
+ pkg := items[m.selectedIndex]
434
+ m.promptPkg = &pkg
435
+ if pkg.Installed {
436
+ m.activePrompt = PromptUninstallConfirm
437
+ m.promptSel = 1
438
+ } else {
439
+ m.activePrompt = PromptInstallMethod
440
+ methods := m.getInstallMethods(&pkg)
441
+ selIdx := 0
442
+ for idx, mth := range methods {
443
+ if mth.Name == m.lastInstallMethod {
444
+ selIdx = idx
445
+ break
446
+ }
447
+ }
448
+ m.promptSel = selIdx
449
+ }
450
+ }
451
+ case "u":
452
+ m.loading = true
453
+ m.statusMsg = "Checking for updates…"
454
+ m.checkingUpdates = true
455
+ return m, m.checkForUpdatesCmd()
456
+ case "g":
457
+ if m.ctx != nil {
458
+ m.settingsInstallMode = m.ctx.Settings.InstallMode
459
+ m.settingsCatalogSort = m.ctx.Settings.CatalogSort
460
+ m.settingsSel = 0
461
+ m.activePrompt = PromptSettings
462
+ }
463
+ }
464
+ return m, nil
465
+
466
+ case contextLoadedMsg:
467
+ m.loading = false
468
+ if msg.Err != nil {
469
+ m.err = msg.Err
470
+ m.statusMsg = fmt.Sprintf("Error loading workspace: %v", msg.Err)
471
+ } else {
472
+ m.ctx = msg.Ctx
473
+ m.statusMsg = "Workspace loaded."
474
+
475
+ if !m.logTailerStarted && m.ctx != nil {
476
+ m.logTailerStarted = true
477
+ logPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.log")
478
+ StartLogTailer(logPath, Program)
479
+ }
480
+ }
481
+
482
+ case catalogLoadedMsg:
483
+ m.loading = false
484
+ if msg.Err != nil {
485
+ m.err = msg.Err
486
+ m.statusMsg = fmt.Sprintf("Error loading catalog: %v", msg.Err)
487
+ } else {
488
+ m.catalog = msg.Cat
489
+ m.statusMsg = fmt.Sprintf("Catalog loaded (%s).", msg.Cat.CacheAge)
490
+ m.checkingUpdates = true
491
+ return m, m.checkForUpdatesCmd()
492
+ }
493
+
494
+ case updatesLoadedMsg:
495
+ m.gitChangesMap = msg.GitChanges
496
+ m.updatesMap = msg.Updates
497
+ m.checkingUpdates = false
498
+ m.loading = false
499
+ count := 0
500
+ for _, up := range msg.Updates {
501
+ if up.LocalGit || up.Npm {
502
+ count++
503
+ }
504
+ }
505
+ if count > 0 {
506
+ m.statusMsg = fmt.Sprintf("Updates check completed: %d update(s) available.", count)
507
+ } else {
508
+ m.statusMsg = "All packages are up to date."
509
+ }
510
+ return m, nil
511
+
512
+ case logLineMsg:
513
+ line := string(msg)
514
+ if strings.Contains(line, "█") || strings.Contains(line, "▄") || strings.Contains(line, "▀") {
515
+ break
516
+ }
517
+ if strings.Contains(line, "Local:") || strings.Contains(line, "Network:") || strings.Contains(line, "expose") || strings.Contains(line, "➜") {
518
+ break
519
+ }
520
+ m.addLog(line)
521
+
522
+ case clearLogsMsg:
523
+ m.logLines = []string{}
524
+ return m, nil
525
+
526
+ case taskFinishedMsg:
527
+ m.taskActive = false
528
+ if msg.Success {
529
+ m.statusMsg = "Task completed."
530
+ m.addLog(">>> Task completed successfully.")
531
+ } else {
532
+ m.statusMsg = fmt.Sprintf("Task failed: %v", msg.Err)
533
+ m.addLog(fmt.Sprintf(">>> Task failed: %v", msg.Err))
534
+ }
535
+ return m, tea.Batch(m.loadContextCmd(), m.loadCatalogCmd(false))
536
+
537
+ case serverStatusMsg:
538
+ m.serverRunning = msg.Running
539
+ m.taskActive = false
540
+ if !msg.Running {
541
+ m.statusMsg = "Dev server stopped."
542
+ } else {
543
+ m.statusMsg = "Dev server running."
544
+ }
545
+ }
546
+
547
+ return m, nil
548
+ }
549
+
550
+ func (m *TuiModel) addLog(line string) {
551
+ m.logLines = append(m.logLines, line)
552
+ if len(m.logLines) > 200 {
553
+ m.logLines = m.logLines[1:]
554
+ }
555
+ }
556
+
557
+ // ─────────────────────────────────────────────
558
+ // Prompt keys
559
+ // ─────────────────────────────────────────────
560
+
561
+ func (m TuiModel) handlePromptKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
562
+ switch msg.String() {
563
+ case "esc", "q":
564
+ m.activePrompt = PromptNone
565
+ m.promptPkg = nil
566
+ return m, nil
567
+ case "up", "k":
568
+ if m.activePrompt == PromptManagePackage {
569
+ if m.promptSel > 0 {
570
+ m.promptSel--
571
+ }
572
+ } else if m.activePrompt == PromptSettings {
573
+ if m.settingsSel == 4 || m.settingsSel == 5 {
574
+ m.settingsSel = 3
575
+ } else if m.settingsSel > 0 {
576
+ m.settingsSel--
577
+ }
578
+ } else if m.promptSel > 0 {
579
+ m.promptSel--
580
+ }
581
+ case "down", "j":
582
+ if m.activePrompt == PromptManagePackage {
583
+ if m.promptSel < 5 {
584
+ m.promptSel++
585
+ }
586
+ } else if m.activePrompt == PromptSettings {
587
+ if m.settingsSel == 3 {
588
+ m.settingsSel = 4
589
+ } else if m.settingsSel < 3 {
590
+ m.settingsSel++
591
+ }
592
+ } else {
593
+ limit := 2
594
+ if m.activePrompt == PromptUninstallConfirm || m.activePrompt == PromptForceReinstallConfirm {
595
+ limit = 1
596
+ } else if m.activePrompt == PromptInstallMethod && m.promptPkg != nil {
597
+ limit = len(m.getInstallMethods(m.promptPkg)) - 1
598
+ }
599
+ if m.promptSel < limit {
600
+ m.promptSel++
601
+ }
602
+ }
603
+ case "left", "h":
604
+ if m.activePrompt == PromptSettings {
605
+ if m.settingsSel == 5 {
606
+ m.settingsSel = 4
607
+ }
608
+ } else if m.activePrompt != PromptManagePackage && m.activePrompt != PromptInstallMethod {
609
+ if m.promptSel > 0 {
610
+ m.promptSel--
611
+ }
612
+ }
613
+ case "right", "l":
614
+ if m.activePrompt == PromptSettings {
615
+ if m.settingsSel == 4 {
616
+ m.settingsSel = 5
617
+ }
618
+ } else if m.activePrompt != PromptManagePackage && m.activePrompt != PromptInstallMethod {
619
+ limit := 2
620
+ if m.activePrompt == PromptUninstallConfirm || m.activePrompt == PromptForceReinstallConfirm {
621
+ limit = 1
622
+ }
623
+ if m.promptSel < limit {
624
+ m.promptSel++
625
+ }
626
+ }
627
+ case "enter":
628
+ pkg := m.promptPkg
629
+ prompt := m.activePrompt
630
+
631
+ if prompt == PromptUninstallConfirm {
632
+ m.activePrompt = PromptNone
633
+ m.promptPkg = nil
634
+ if m.promptSel == 0 {
635
+ return m.triggerUninstall(pkg)
636
+ }
637
+ } else if prompt == PromptInstallMethod {
638
+ m.activePrompt = PromptNone
639
+ m.promptPkg = nil
640
+ methods := m.getInstallMethods(pkg)
641
+ if m.promptSel >= 0 && m.promptSel < len(methods) {
642
+ m.lastInstallMethod = methods[m.promptSel].Name
643
+ return m.triggerInstall(pkg, methods[m.promptSel].Name)
644
+ }
645
+ return m, nil
646
+ } else if prompt == PromptForceReinstallConfirm {
647
+ m.activePrompt = PromptNone
648
+ m.promptPkg = nil
649
+ if m.promptSel == 0 {
650
+ return m.triggerForceReinstall(pkg)
651
+ }
652
+ } else if prompt == PromptManagePackage {
653
+ m.activePrompt = PromptNone
654
+ m.promptPkg = nil
655
+ switch m.promptSel {
656
+ case 0: // Update Package
657
+ return m.triggerUpdate(pkg)
658
+ case 1: // Switch to NPM
659
+ m.lastInstallMethod = "npm"
660
+ return m.triggerInstall(pkg, "npm")
661
+ case 2: // Switch to Git HTTPS
662
+ m.lastInstallMethod = "git-https"
663
+ return m.triggerInstall(pkg, "git-https")
664
+ case 3: // Switch to Git SSH
665
+ m.lastInstallMethod = "git-ssh"
666
+ return m.triggerInstall(pkg, "git-ssh")
667
+ case 4: // Force Re-download
668
+ m.promptPkg = pkg
669
+ m.activePrompt = PromptForceReinstallConfirm
670
+ m.promptSel = 1 // default to No
671
+ return m, nil
672
+ case 5: // Back
673
+ return m, nil
674
+ }
675
+ } else if prompt == PromptSettings {
676
+ switch m.settingsSel {
677
+ case 0: // Install Mode
678
+ if m.settingsInstallMode == "npm" {
679
+ m.settingsInstallMode = "workspace"
680
+ } else {
681
+ m.settingsInstallMode = "npm"
682
+ }
683
+ m.activePrompt = PromptSettings
684
+ return m, nil
685
+ case 1: // Catalog Sort
686
+ modes := []string{"updated", "name", "stars", "installed"}
687
+ idx := 0
688
+ for i, mth := range modes {
689
+ if mth == m.settingsCatalogSort {
690
+ idx = (i + 1) % len(modes)
691
+ break
692
+ }
693
+ }
694
+ m.settingsCatalogSort = modes[idx]
695
+ m.activePrompt = PromptSettings
696
+ return m, nil
697
+ case 2: // Trusted Orgs
698
+ m.addLog(">>> To configure Trusted Orgs, modify .desktop/settings.json (githubOrgs field).")
699
+ m.activePrompt = PromptSettings
700
+ return m, nil
701
+ case 3: // GitHub User
702
+ m.addLog(">>> To configure GitHub User, modify .desktop/settings.json or use OWD_GITHUB_USER env var.")
703
+ m.activePrompt = PromptSettings
704
+ return m, nil
705
+ case 4: // Save
706
+ m.activePrompt = PromptNone
707
+ if m.ctx != nil {
708
+ settings := m.ctx.Settings
709
+ settings.InstallMode = m.settingsInstallMode
710
+ settings.CatalogSort = m.settingsCatalogSort
711
+ payload := &bridge.WritePayload{
712
+ Settings: &settings,
713
+ }
714
+ m.statusMsg = "Saving settings…"
715
+ m.addLog(">>> Saving settings configuration…")
716
+ if err := bridge.WriteChanges(m.workspaceRoot, payload); err != nil {
717
+ m.statusMsg = fmt.Sprintf("Save failed: %v", err)
718
+ m.addLog(fmt.Sprintf(">>> Save settings failed: %v", err))
719
+ } else {
720
+ m.statusMsg = "Settings saved successfully."
721
+ m.addLog(">>> Settings saved successfully.")
722
+ }
723
+ }
724
+ m.loading = true
725
+ return m, tea.Batch(m.loadContextCmd(), m.loadCatalogCmd(false))
726
+ case 5: // Cancel
727
+ m.activePrompt = PromptNone
728
+ return m, nil
729
+ }
730
+ }
731
+ }
732
+ return m, nil
733
+ }
734
+
735
+ // ─────────────────────────────────────────────
736
+ // Actions
737
+ // ─────────────────────────────────────────────
738
+
739
+ func (m TuiModel) triggerUninstall(pkg *bridge.CatalogEntry) (tea.Model, tea.Cmd) {
740
+ m.taskActive = true
741
+ m.statusMsg = fmt.Sprintf("Uninstalling %s…", pkg.ShortName)
742
+ m.addLog(fmt.Sprintf(">>> Uninstalling %s", pkg.Name))
743
+
744
+ payload := &bridge.WritePayload{
745
+ Config: &bridge.Config{Theme: m.ctx.Config.Theme, Apps: m.ctx.Config.Apps, Modules: m.ctx.Config.Modules},
746
+ DepsToRemove: []string{pkg.Name},
747
+ }
748
+ if pkg.Kind == "app" {
749
+ var next []string
750
+ for _, a := range m.ctx.Config.Apps {
751
+ if a != pkg.Name {
752
+ next = append(next, a)
753
+ }
754
+ }
755
+ payload.Config.Apps = next
756
+ } else if pkg.Kind == "module" {
757
+ var next []string
758
+ for _, mod := range m.ctx.Config.Modules {
759
+ if mod != pkg.Name {
760
+ next = append(next, mod)
761
+ }
762
+ }
763
+ payload.Config.Modules = next
764
+ } else if pkg.Kind == "theme" {
765
+ e := ""
766
+ payload.Config.Theme = &e
767
+ }
768
+
769
+ if err := bridge.WriteChanges(m.workspaceRoot, payload); err != nil {
770
+ m.taskActive = false
771
+ m.statusMsg = fmt.Sprintf("Uninstall failed: %v", err)
772
+ return m, nil
773
+ }
774
+ if Program != nil {
775
+ m.RunUninstallTask(Program)
776
+ }
777
+ return m, nil
778
+ }
779
+
780
+ func (m TuiModel) triggerInstall(pkg *bridge.CatalogEntry, method string) (tea.Model, tea.Cmd) {
781
+ m.taskActive = true
782
+ m.statusMsg = fmt.Sprintf("Installing %s via %s…", pkg.ShortName, method)
783
+ m.addLog(fmt.Sprintf(">>> Installing %s via %s", pkg.Name, method))
784
+
785
+ payload := &bridge.WritePayload{
786
+ Config: &bridge.Config{Theme: m.ctx.Config.Theme, Apps: m.ctx.Config.Apps, Modules: m.ctx.Config.Modules},
787
+ DepsToAdd: make(map[string]string),
788
+ }
789
+ version := "latest"
790
+ if pkg.Version != nil {
791
+ version = *pkg.Version
792
+ }
793
+
794
+ if method == "npm" {
795
+ payload.DepsToAdd[pkg.Name] = version
796
+ } else if method == "local" {
797
+ payload.DepsToAdd[pkg.Name] = "workspace:*"
798
+ } else {
799
+ user := "owdproject"
800
+ if m.ctx.Settings.GithubUser != nil && *m.ctx.Settings.GithubUser != "" {
801
+ user = *m.ctx.Settings.GithubUser
802
+ }
803
+ var gitUrl string
804
+ if method == "git-ssh" {
805
+ gitUrl = fmt.Sprintf("git@github.com:%s/%s.git", user, pkg.ShortName)
806
+ } else {
807
+ gitUrl = fmt.Sprintf("https://github.com/%s/%s.git", user, pkg.ShortName)
808
+ }
809
+ payload.DepsToAdd[pkg.Name] = "workspace:*"
810
+ settings := m.ctx.Settings
811
+ if settings.LastInstallChoices == nil {
812
+ settings.LastInstallChoices = make(map[string]interface{})
813
+ }
814
+ settings.LastInstallChoices[pkg.Name] = map[string]string{"type": "git", "gitUrl": gitUrl}
815
+ payload.Settings = &settings
816
+ }
817
+
818
+ if pkg.Kind == "app" {
819
+ payload.Config.Apps = append(payload.Config.Apps, pkg.Name)
820
+ } else if pkg.Kind == "module" {
821
+ payload.Config.Modules = append(payload.Config.Modules, pkg.Name)
822
+ } else if pkg.Kind == "theme" {
823
+ payload.Config.Theme = &pkg.Name
824
+ }
825
+
826
+ if err := bridge.WriteChanges(m.workspaceRoot, payload); err != nil {
827
+ m.taskActive = false
828
+ m.statusMsg = fmt.Sprintf("Install failed: %v", err)
829
+ return m, nil
830
+ }
831
+ if Program != nil {
832
+ m.RunInstallTask(pkg.Name, method, Program)
833
+ }
834
+ return m, nil
835
+ }
836
+
837
+ func (m TuiModel) triggerForceReinstall(pkg *bridge.CatalogEntry) (tea.Model, tea.Cmd) {
838
+ m.taskActive = true
839
+ m.statusMsg = fmt.Sprintf("Force reinstalling %s…", pkg.ShortName)
840
+ m.addLog(fmt.Sprintf(">>> Deleting local folder and reinstalling %s", pkg.Name))
841
+
842
+ // Delete local folder
843
+ short := pkg.ShortName
844
+ kindDir := ""
845
+ switch pkg.Kind {
846
+ case "app":
847
+ kindDir = "apps"
848
+ case "module":
849
+ kindDir = "packages"
850
+ case "theme":
851
+ kindDir = "themes"
852
+ }
853
+ if kindDir != "" {
854
+ pkgPath := filepath.Join(m.workspaceRoot, kindDir, short)
855
+ m.addLog(fmt.Sprintf(">>> Removing directory: %s", pkgPath))
856
+ _ = os.RemoveAll(pkgPath)
857
+ }
858
+
859
+ // Reinstall using the original source method
860
+ method := "npm"
861
+ if pkg.LocalSource {
862
+ if m.ctx != nil && m.ctx.Settings.LastInstallChoices != nil {
863
+ if choice, exists := m.ctx.Settings.LastInstallChoices[pkg.Name]; exists {
864
+ if choiceMap, ok := choice.(map[string]interface{}); ok {
865
+ if gitUrl, hasGit := choiceMap["gitUrl"].(string); hasGit {
866
+ if strings.HasPrefix(gitUrl, "git@") {
867
+ method = "git-ssh"
868
+ } else {
869
+ method = "git-https"
870
+ }
871
+ }
872
+ }
873
+ }
874
+ }
875
+ }
876
+
877
+ if Program != nil {
878
+ m.RunInstallTask(pkg.Name, method, Program)
879
+ }
880
+ return m, nil
881
+ }
882
+
883
+ func (m TuiModel) triggerUpdate(pkg *bridge.CatalogEntry) (tea.Model, tea.Cmd) {
884
+ m.taskActive = true
885
+ m.statusMsg = fmt.Sprintf("Updating %s…", pkg.ShortName)
886
+ m.addLog(fmt.Sprintf(">>> Updating %s", pkg.Name))
887
+
888
+ if Program != nil {
889
+ go func() {
890
+ short := pkg.ShortName
891
+ kindDir := ""
892
+ switch pkg.Kind {
893
+ case "app":
894
+ kindDir = "apps"
895
+ case "module":
896
+ kindDir = "packages"
897
+ case "theme":
898
+ kindDir = "themes"
899
+ }
900
+ if kindDir != "" {
901
+ pkgPath := filepath.Join(m.workspaceRoot, kindDir, short)
902
+ gitDir := filepath.Join(pkgPath, ".git")
903
+ if _, err := os.Stat(gitDir); err == nil {
904
+ Program.Send(logLineMsg(">>> Local Git repository detected. Running git pull…"))
905
+ runProcessAndStreamLogs(pkgPath, "git", []string{"pull"}, Program)
906
+ Program.Send(taskFinishedMsg{Success: true})
907
+ return
908
+ }
909
+ }
910
+
911
+ Program.Send(logLineMsg(fmt.Sprintf(">>> Running pnpm install %s@latest…", pkg.Name)))
912
+ runProcessAndStreamLogs(m.workspaceRoot, "pnpm", []string{"install", pkg.Name + "@latest"}, Program)
913
+ Program.Send(logLineMsg(">>> Preparing workspace modules…"))
914
+ runProcessAndStreamLogs(m.workspaceRoot, "pnpm", []string{"run", "prepare:modules"}, Program)
915
+ Program.Send(taskFinishedMsg{Success: true})
916
+ }()
917
+ }
918
+ return m, nil
919
+ }
920
+
921
+ // ─────────────────────────────────────────────
922
+ // Helpers
923
+ // ─────────────────────────────────────────────
924
+
925
+ func (m TuiModel) getActiveItems() []bridge.CatalogEntry {
926
+ if m.catalog == nil {
927
+ return nil
928
+ }
929
+ var res []bridge.CatalogEntry
930
+ for _, e := range m.catalog.Entries {
931
+ switch m.activeTab {
932
+ case 0: // Internal
933
+ if e.Kind == "module" && !strings.HasPrefix(e.ShortName, "module-") {
934
+ res = append(res, e)
935
+ }
936
+ case 1: // Apps
937
+ if e.Kind == "app" {
938
+ res = append(res, e)
939
+ }
940
+ case 2: // Modules
941
+ if e.Kind == "module" && strings.HasPrefix(e.ShortName, "module-") {
942
+ res = append(res, e)
943
+ }
944
+ case 3: // Themes
945
+ if e.Kind == "theme" {
946
+ res = append(res, e)
947
+ }
948
+ }
949
+ }
950
+ return res
951
+ }
952
+
953
+ func sparkline(vals []int) string {
954
+ blocks := []rune{'\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'}
955
+ if len(vals) == 0 {
956
+ mutedBar := lipgloss.NewStyle().Foreground(colorDim).Render(string('\u2581'))
957
+ return strings.Repeat(mutedBar, 12)
958
+ }
959
+
960
+ min := vals[0]
961
+ max := vals[0]
962
+ for _, v := range vals {
963
+ if v < min {
964
+ min = v
965
+ }
966
+ if v > max {
967
+ max = v
968
+ }
969
+ }
970
+
971
+ var sb strings.Builder
972
+ show := vals
973
+ if len(show) > 12 {
974
+ show = show[len(show)-12:]
975
+ }
976
+
977
+ for _, v := range show {
978
+ var idx int
979
+ if max == min {
980
+ if v == 0 {
981
+ idx = 0
982
+ } else {
983
+ idx = 2
984
+ }
985
+ } else {
986
+ idx = int(float64(v-min) / float64(max-min) * float64(len(blocks)-1))
987
+ if idx < 0 {
988
+ idx = 0
989
+ }
990
+ if idx >= len(blocks) {
991
+ idx = len(blocks) - 1
992
+ }
993
+ }
994
+
995
+ var color lipgloss.Color
996
+ if v == 0 {
997
+ color = colorDim
998
+ } else {
999
+ if v > 1024 {
1000
+ color = colorErr
1001
+ } else if v > 512 {
1002
+ color = colorWarn
1003
+ } else {
1004
+ color = colorCyan
1005
+ }
1006
+ }
1007
+
1008
+ styledRune := lipgloss.NewStyle().Foreground(color).Render(string(blocks[idx]))
1009
+ sb.WriteString(styledRune)
1010
+ }
1011
+ return sb.String()
1012
+ }
1013
+
1014
+ func gitBranch(root string) string {
1015
+ out, err := exec.Command("git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD").Output()
1016
+ if err != nil {
1017
+ return "—"
1018
+ }
1019
+ return strings.TrimSpace(string(out))
1020
+ }
1021
+
1022
+ func gitChanges(root string) string {
1023
+ out, err := exec.Command("git", "-C", root, "status", "--porcelain").Output()
1024
+ if err != nil {
1025
+ return "unknown"
1026
+ }
1027
+ lines := strings.Split(strings.TrimSpace(string(out)), "\n")
1028
+ if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
1029
+ return "clean"
1030
+ }
1031
+ mod, add, del := 0, 0, 0
1032
+ for _, l := range lines {
1033
+ if len(l) < 2 {
1034
+ continue
1035
+ }
1036
+ switch {
1037
+ case strings.HasPrefix(l, "??"):
1038
+ add++
1039
+ case strings.HasPrefix(l, " D") || strings.HasPrefix(l, "D "):
1040
+ del++
1041
+ default:
1042
+ mod++
1043
+ }
1044
+ }
1045
+ var parts []string
1046
+ if add > 0 {
1047
+ parts = append(parts, accentStyle.Render(fmt.Sprintf("+%d", add)))
1048
+ }
1049
+ if mod > 0 {
1050
+ parts = append(parts, warnStyle.Render(fmt.Sprintf("~%d", mod)))
1051
+ }
1052
+ if del > 0 {
1053
+ parts = append(parts, errStyle.Render(fmt.Sprintf("-%d", del)))
1054
+ }
1055
+ if len(parts) == 0 {
1056
+ return "clean"
1057
+ }
1058
+ return strings.Join(parts, " ")
1059
+ }
1060
+
1061
+ func countLocalDirs(root, subdir string, excludePrefixes ...string) int {
1062
+ dir := filepath.Join(root, subdir)
1063
+ entries, err := os.ReadDir(dir)
1064
+ if err != nil {
1065
+ return 0
1066
+ }
1067
+ count := 0
1068
+ for _, e := range entries {
1069
+ if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
1070
+ continue
1071
+ }
1072
+ skip := false
1073
+ for _, pf := range excludePrefixes {
1074
+ if strings.HasPrefix(e.Name(), pf) {
1075
+ skip = true
1076
+ break
1077
+ }
1078
+ }
1079
+ if !skip {
1080
+ count++
1081
+ }
1082
+ }
1083
+ return count
1084
+ }
1085
+
1086
+ func truncate(s string, max int) string {
1087
+ if len(s) <= max {
1088
+ return s
1089
+ }
1090
+ return s[:max-1] + "…"
1091
+ }
1092
+
1093
+ func padRight(s string, width int) string {
1094
+ if len(s) >= width {
1095
+ return s[:width]
1096
+ }
1097
+ return s + strings.Repeat(" ", width-len(s))
1098
+ }
1099
+
1100
+ func padLeft(s string, width int) string {
1101
+ if len(s) >= width {
1102
+ return s[:width]
1103
+ }
1104
+ return strings.Repeat(" ", width-len(s)) + s
1105
+ }
1106
+
1107
+ func formatCatalogAge(iso *string) string {
1108
+ if iso == nil || *iso == "" {
1109
+ return ""
1110
+ }
1111
+ t, err := time.Parse(time.RFC3339, *iso)
1112
+ if err != nil {
1113
+ t, err = time.Parse("2006-01-02T15:04:05.000Z", *iso)
1114
+ if err != nil {
1115
+ t, err = time.Parse("2006-01-02T15:04:05Z", *iso)
1116
+ if err != nil {
1117
+ return ""
1118
+ }
1119
+ }
1120
+ }
1121
+ duration := time.Since(t)
1122
+ days := int(duration.Hours() / 24)
1123
+ if days < 0 {
1124
+ return "<1d"
1125
+ }
1126
+ if days < 1 {
1127
+ return "<1d"
1128
+ }
1129
+ if days < 7 {
1130
+ return fmt.Sprintf("%dd", days)
1131
+ }
1132
+ if days < 30 {
1133
+ return fmt.Sprintf("%dw", days/7)
1134
+ }
1135
+ return fmt.Sprintf("%dmo", days/30)
1136
+ }
1137
+
1138
+ // overlayCenter renders `overlay` centered on top of `bg` (both already rendered, ANSI-aware widths).
1139
+ func overlayCenter(bg, overlay string) string {
1140
+ bgLines := strings.Split(bg, "\n")
1141
+ ovLines := strings.Split(overlay, "\n")
1142
+ bgH := len(bgLines)
1143
+ ovH := len(ovLines)
1144
+ bgW := 0
1145
+ for _, l := range bgLines {
1146
+ if w := lipgloss.Width(l); w > bgW {
1147
+ bgW = w
1148
+ }
1149
+ }
1150
+ ovW := 0
1151
+ for _, l := range ovLines {
1152
+ if w := lipgloss.Width(l); w > ovW {
1153
+ ovW = w
1154
+ }
1155
+ }
1156
+ topPad := (bgH - ovH) / 2
1157
+ leftPad := (bgW - ovW) / 2
1158
+ if leftPad < 0 {
1159
+ leftPad = 0
1160
+ }
1161
+ out := make([]string, bgH)
1162
+ copy(out, bgLines)
1163
+ for i, ovLine := range ovLines {
1164
+ row := topPad + i
1165
+ if row < 0 || row >= bgH {
1166
+ continue
1167
+ }
1168
+ bg := out[row]
1169
+ bgVisible := lipgloss.Width(bg)
1170
+ // pad bg line to bgW if shorter
1171
+ if bgVisible < bgW {
1172
+ bg = bg + strings.Repeat(" ", bgW-bgVisible)
1173
+ }
1174
+ // split bg into runes accounting for ANSI — use a simple approach:
1175
+ // write leftPad chars of bg, then overlay line, then rest of bg
1176
+ bgStripped := lipgloss.NewStyle().Render(bg) // no-op, keeps ansi
1177
+ // For simplicity: rebuild as left + overlay + right using byte slicing on stripped
1178
+ // We use lipgloss.Width-aware padding approach
1179
+ prefix := strings.Repeat(" ", leftPad)
1180
+ suffix := ""
1181
+ rightStart := leftPad + ovW
1182
+ if rightStart < bgW {
1183
+ suffix = strings.Repeat(" ", bgW-rightStart)
1184
+ }
1185
+ _ = bgStripped
1186
+ out[row] = prefix + ovLine + suffix
1187
+ }
1188
+ return strings.Join(out, "\n")
1189
+ }
1190
+
1191
+ // ─────────────────────────────────────────────
1192
+ // Rendering helpers
1193
+ // ─────────────────────────────────────────────
1194
+
1195
+ // Panel drawing helper with border labels
1196
+ func drawPanel(w, h int, title string, content string, active bool) string {
1197
+ style := panelBorderStyle
1198
+ if active {
1199
+ style = panelBorderActive
1200
+ }
1201
+
1202
+ // Ensure the style has the correct dimensions
1203
+ style = style.Width(w - 2).Height(h - 2)
1204
+
1205
+ rendered := style.Render(content)
1206
+
1207
+ if title == "" {
1208
+ return rendered
1209
+ }
1210
+
1211
+ // If the title contains escape sequences, use it as is; otherwise format as bold white
1212
+ var styledTitle string
1213
+ if strings.Contains(title, "\x1b") {
1214
+ styledTitle = title
1215
+ } else {
1216
+ styledTitle = lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(title)
1217
+ }
1218
+
1219
+ titleFmt := " " + styledTitle + " "
1220
+ titleWidth := lipgloss.Width(titleFmt)
1221
+
1222
+ // Replace the first line's center with the title
1223
+ lines := strings.Split(rendered, "\n")
1224
+ if len(lines) > 0 {
1225
+ borderColor := colorBorder
1226
+ if active {
1227
+ borderColor = colorBorderAct
1228
+ }
1229
+
1230
+ dashCount := w - 3 - titleWidth
1231
+ if dashCount < 2 {
1232
+ dashCount = 2
1233
+ }
1234
+
1235
+ borderStyle := lipgloss.NewStyle().Foreground(borderColor)
1236
+
1237
+ topBorder := borderStyle.Render("╭─") +
1238
+ titleFmt +
1239
+ borderStyle.Render(strings.Repeat("─", dashCount)+"╮")
1240
+
1241
+ lines[0] = topBorder
1242
+ rendered = strings.Join(lines, "\n")
1243
+ }
1244
+
1245
+ return rendered
1246
+ }
1247
+
1248
+ type WorkspaceVersions struct {
1249
+ Nuxt string
1250
+ Pnpm string
1251
+ Owd string
1252
+ }
1253
+
1254
+ func getVersions(root string, workspaceRoot string) WorkspaceVersions {
1255
+ vers := WorkspaceVersions{Nuxt: "—", Pnpm: "—", Owd: "—"}
1256
+
1257
+ // Read root package.json (monorepo or playground package)
1258
+ rootPkgPath := filepath.Join(root, "package.json")
1259
+ if data, err := os.ReadFile(rootPkgPath); err == nil {
1260
+ lines := strings.Split(string(data), "\n")
1261
+ for _, l := range lines {
1262
+ if strings.Contains(l, `"packageManager"`) && strings.Contains(l, "pnpm@") {
1263
+ parts := strings.Split(l, "pnpm@")
1264
+ if len(parts) >= 2 {
1265
+ vers.Pnpm = strings.Trim(parts[1], " \t\r\n,\"")
1266
+ vers.Pnpm = strings.ReplaceAll(vers.Pnpm, `"`, "")
1267
+ vers.Pnpm = strings.TrimSpace(vers.Pnpm)
1268
+ }
1269
+ }
1270
+ if strings.Contains(l, `"nuxt"`) {
1271
+ parts := strings.Split(l, ":")
1272
+ if len(parts) >= 2 {
1273
+ v := strings.Trim(parts[1], " \t\r\n,\"^~")
1274
+ v = strings.ReplaceAll(v, `"`, "")
1275
+ vers.Nuxt = strings.TrimSpace(v)
1276
+ }
1277
+ }
1278
+ }
1279
+ }
1280
+
1281
+ // Read OWD Core version from workspaceRoot/packages/core/package.json
1282
+ corePkgPath := filepath.Join(workspaceRoot, "packages", "core", "package.json")
1283
+ if data, err := os.ReadFile(corePkgPath); err == nil {
1284
+ lines := strings.Split(string(data), "\n")
1285
+ for _, l := range lines {
1286
+ if strings.Contains(l, `"version"`) {
1287
+ parts := strings.Split(l, ":")
1288
+ if len(parts) >= 2 {
1289
+ v := strings.Trim(parts[1], " \t\r\n,\"")
1290
+ v = strings.ReplaceAll(v, `"`, "")
1291
+ vers.Owd = strings.TrimSpace(v)
1292
+ break
1293
+ }
1294
+ }
1295
+ }
1296
+ }
1297
+
1298
+ return vers
1299
+ }
1300
+
1301
+ func getProcessMemoryMB(pid int) int {
1302
+ if pid <= 0 {
1303
+ return 0
1304
+ }
1305
+ path := fmt.Sprintf("/proc/%d/status", pid)
1306
+ data, err := os.ReadFile(path)
1307
+ if err != nil {
1308
+ return 0
1309
+ }
1310
+ lines := strings.Split(string(data), "\n")
1311
+ for _, l := range lines {
1312
+ if strings.HasPrefix(l, "VmRSS:") {
1313
+ fields := strings.Fields(l)
1314
+ if len(fields) >= 2 {
1315
+ var kb int
1316
+ if _, err := fmt.Sscanf(fields[1], "%d", &kb); err == nil {
1317
+ return kb / 1024
1318
+ }
1319
+ }
1320
+ }
1321
+ }
1322
+ return 0
1323
+ }
1324
+
1325
+ func renderPanelTitle(title string) string {
1326
+ return lipgloss.NewStyle().
1327
+ Foreground(colorWhite).
1328
+ Bold(true).
1329
+ Render(title)
1330
+ }
1331
+
1332
+ func kv(label, value string) string {
1333
+ return mutedStyle.Render(padRight(label, 10)) + " " + boldStyle.Render(value)
1334
+ }
1335
+
1336
+ func kvRaw(label, value string) string {
1337
+ return mutedStyle.Render(padRight(label, 10)) + " " + value
1338
+ }
1339
+
1340
+ func pill(text string, color lipgloss.Color) string {
1341
+ return lipgloss.NewStyle().Foreground(color).Bold(true).Render(text)
1342
+ }
1343
+
1344
+ // ─────────────────────────────────────────────
1345
+ // View
1346
+ // ─────────────────────────────────────────────
1347
+
1348
+ func (m TuiModel) View() string {
1349
+ w := m.termWidth
1350
+ h := m.termHeight
1351
+ if w < 40 {
1352
+ w = 40
1353
+ }
1354
+ if h < 10 {
1355
+ h = 10
1356
+ }
1357
+
1358
+ showLogs := (m.serverRunning || m.taskActive) && w >= 120
1359
+
1360
+ // Heights
1361
+ topH := 9 // top panels row (with borders = 9 rows)
1362
+ barH := 4 // status bar lines (including prepended/trailing newlines)
1363
+ // catalog gets the remaining height
1364
+ catalogH := h - topH - barH
1365
+ if catalogH < 4 {
1366
+ catalogH = 4
1367
+ }
1368
+
1369
+ // Column widths (including borders each panel takes +2 cols)
1370
+ var leftW, midW, rightW int
1371
+ if showLogs {
1372
+ // 40% | 28% | 32% — roughly matching original blessed layout
1373
+ rightW = w * 32 / 100
1374
+ leftW = w * 40 / 100
1375
+ midW = w - leftW - rightW
1376
+ } else {
1377
+ leftW = w * 58 / 100
1378
+ midW = w - leftW
1379
+ rightW = 0
1380
+ }
1381
+
1382
+ // ── Render panel contents ─────────────────────────────
1383
+ leftContent := m.renderClientPanel(leftW-4, topH-2)
1384
+ midContent := m.renderMetricsPanel(midW-4, topH-2)
1385
+
1386
+ desktopTitle := lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render("Desktop") + " " +
1387
+ lipgloss.NewStyle().Foreground(colorMuted).Render("control panel")
1388
+
1389
+ var topRow, mainContent, catalogPanel string
1390
+
1391
+ if showLogs {
1392
+ catW := leftW + midW
1393
+ logContent := m.renderLogsPanel(rightW-4, h-barH-2)
1394
+
1395
+ // Top 2-panel row (left + mid)
1396
+ topTwoPanels := lipgloss.JoinHorizontal(lipgloss.Top,
1397
+ drawPanel(leftW-1, topH, desktopTitle, leftContent, false),
1398
+ drawPanel(midW, topH, "Metrics", midContent, false),
1399
+ )
1400
+
1401
+ // Catalog panel — always rendered; modal overlaid on top if active
1402
+ catalogContent := m.renderCatalogPanel(catW-4, catalogH-2, showLogs)
1403
+ catalogPanel = drawPanel(catW-1, catalogH, "Catalog", catalogContent, true)
1404
+ if m.activePrompt != PromptNone {
1405
+ modal := m.renderModal()
1406
+ styledModal := lipgloss.NewStyle().
1407
+ Border(lipgloss.RoundedBorder()).
1408
+ BorderForeground(colorCyan).
1409
+ Padding(1, 3).
1410
+ Render(modal)
1411
+ catalogPanel = overlayCenter(catalogPanel, styledModal)
1412
+ }
1413
+
1414
+ // Left main block = top panels + catalog stacked
1415
+ leftMain := lipgloss.JoinVertical(lipgloss.Left, topTwoPanels, catalogPanel)
1416
+
1417
+ // Right logs panel, spans full height
1418
+ logsPanel := drawPanel(rightW, h-barH, "Logs", logContent, false)
1419
+
1420
+ topRow = lipgloss.JoinHorizontal(lipgloss.Top, leftMain, logsPanel)
1421
+ mainContent = topRow
1422
+ } else {
1423
+ // No logs: top row = left + right panels side by side
1424
+ topBlock := lipgloss.JoinHorizontal(lipgloss.Top,
1425
+ drawPanel(leftW-1, topH, desktopTitle, leftContent, false),
1426
+ drawPanel(midW, topH, "Metrics", midContent, false),
1427
+ )
1428
+
1429
+ // Catalog full width — always rendered; modal overlaid on top if active
1430
+ catalogContent := m.renderCatalogPanel(w-4, catalogH-2, showLogs)
1431
+ catalogPanel = drawPanel(w, catalogH, "Catalog", catalogContent, true)
1432
+ if m.activePrompt != PromptNone {
1433
+ modal := m.renderModal()
1434
+ styledModal := lipgloss.NewStyle().
1435
+ Border(lipgloss.RoundedBorder()).
1436
+ BorderForeground(colorCyan).
1437
+ Padding(1, 3).
1438
+ Render(modal)
1439
+ catalogPanel = overlayCenter(catalogPanel, styledModal)
1440
+ }
1441
+
1442
+ mainContent = lipgloss.JoinVertical(lipgloss.Left, topBlock, catalogPanel)
1443
+ }
1444
+
1445
+ // ── Status bar (borderless) ───────────────────────────
1446
+ statusBar := m.renderStatusBar(w)
1447
+
1448
+ return mainContent + statusBar
1449
+ }
1450
+
1451
+ // ─────────────────────────────────────────────
1452
+ // Panel: Desktop Control Panel (left)
1453
+ // ─────────────────────────────────────────────
1454
+
1455
+ func (m TuiModel) renderClientPanel(w, h int) string {
1456
+ var lines []string
1457
+
1458
+ // Server status line
1459
+ var statusLine string
1460
+ if m.taskActive && strings.Contains(strings.ToLower(m.statusMsg), "stopping") {
1461
+ spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
1462
+ frame := spinnerFrames[m.tickCount%len(spinnerFrames)]
1463
+ dot := lipgloss.NewStyle().Foreground(colorWarn).Bold(true).Render(frame)
1464
+ statusLine = dot + " " + boldStyle.Render("STOPPING") + " " + mutedStyle.Render("http://localhost:3000")
1465
+ } else if m.taskActive && strings.Contains(strings.ToLower(m.statusMsg), "starting") {
1466
+ spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
1467
+ frame := spinnerFrames[m.tickCount%len(spinnerFrames)]
1468
+ dot := lipgloss.NewStyle().Foreground(colorCyan).Bold(true).Render(frame)
1469
+ statusLine = dot + " " + boldStyle.Render("STARTING") + " " + mutedStyle.Render("http://localhost:3000")
1470
+ } else if m.serverRunning {
1471
+ dot := accentStyle.Render("●")
1472
+ if m.blink {
1473
+ dot = lipgloss.NewStyle().Foreground(lipgloss.Color("#2ea043")).Bold(true).Render("●")
1474
+ }
1475
+ url := lipgloss.NewStyle().Foreground(colorCyan).Render("http://localhost:3000")
1476
+ statusLine = dot + " " + boldStyle.Render("RUNNING") + " " + url
1477
+ if m.ctx != nil {
1478
+ _ = m.ctx
1479
+ }
1480
+ } else if m.taskActive {
1481
+ spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
1482
+ frame := spinnerFrames[m.tickCount%len(spinnerFrames)]
1483
+ dot := lipgloss.NewStyle().Foreground(colorWarn).Bold(true).Render(frame)
1484
+ statusLine = dot + " " + boldStyle.Render("STARTING") + " " + mutedStyle.Render("http://localhost:3000")
1485
+ } else {
1486
+ dot := errStyle.Render("○")
1487
+ statusLine = dot + " " + boldStyle.Render("STOPPED") + " " + mutedStyle.Render("http://localhost:3000")
1488
+ }
1489
+ lines = append(lines, " "+statusLine)
1490
+ lines = append(lines, "") // Space out server status line
1491
+ lines = append(lines, mutedStyle.Render(" System ")+boldStyle.Render("ready"))
1492
+
1493
+ // Theme
1494
+ theme := "—"
1495
+ if m.ctx != nil && m.ctx.Config.Theme != nil && *m.ctx.Config.Theme != "" {
1496
+ t := *m.ctx.Config.Theme
1497
+ if idx := strings.LastIndex(t, "/"); idx >= 0 {
1498
+ t = t[idx+1:]
1499
+ }
1500
+ theme = t
1501
+ }
1502
+ lines = append(lines, mutedStyle.Render(" Theme ")+boldStyle.Render(theme))
1503
+
1504
+ // Workspace
1505
+ wsPath := "—"
1506
+ if m.ctx != nil {
1507
+ if m.ctx.Paths.IsPlayground {
1508
+ wsPath = strings.Replace(m.ctx.Paths.Desktop, os.Getenv("HOME"), "~", 1)
1509
+ } else {
1510
+ wsPath = strings.Replace(m.ctx.WorkspaceRoot, os.Getenv("HOME"), "~", 1)
1511
+ }
1512
+ } else if m.workspaceRoot != "" {
1513
+ wsPath = strings.Replace(m.workspaceRoot, os.Getenv("HOME"), "~", 1)
1514
+ }
1515
+ wsPath = truncate(wsPath, w-14)
1516
+ lines = append(lines, mutedStyle.Render(" Workspace ")+boldStyle.Render(wsPath))
1517
+
1518
+ // Git
1519
+ root := m.workspaceRoot
1520
+ workspaceRoot := m.workspaceRoot
1521
+ if m.ctx != nil {
1522
+ workspaceRoot = m.ctx.WorkspaceRoot
1523
+ if m.ctx.Paths.IsPlayground {
1524
+ root = m.ctx.Paths.PackageDir
1525
+ } else {
1526
+ root = m.ctx.WorkspaceRoot
1527
+ }
1528
+ }
1529
+ branch := gitBranch(root)
1530
+ changes := gitChanges(root)
1531
+ gitLine := "branch: " + accentStyle.Render(branch) + " · changes: " + changes
1532
+ lines = append(lines, mutedStyle.Render(" Git ")+gitLine)
1533
+
1534
+ // Versions
1535
+ vers := getVersions(root, workspaceRoot)
1536
+ lines = append(lines, mutedStyle.Render(" Runtime ")+boldStyle.Render(fmt.Sprintf("Nuxt v%s · OWD v%s · pnpm v%s", vers.Nuxt, vers.Owd, vers.Pnpm)))
1537
+
1538
+ content := strings.Join(lines, "\n")
1539
+ return content
1540
+ }
1541
+
1542
+ // ─────────────────────────────────────────────
1543
+ // Panel: Metrics (right of control panel)
1544
+ // ─────────────────────────────────────────────
1545
+
1546
+ func (m TuiModel) renderMetricsPanel(w, h int) string {
1547
+ var lines []string
1548
+
1549
+ // Memory sparkline
1550
+ spark := sparkline(m.memHistory)
1551
+ memVal := "—"
1552
+ if len(m.memHistory) > 0 && m.memHistory[len(m.memHistory)-1] > 0 {
1553
+ memVal = fmt.Sprintf("%d MiB", m.memHistory[len(m.memHistory)-1])
1554
+ }
1555
+ memLine := mutedStyle.Render(padRight("Memory", 9)) + " " + boldStyle.Render(memVal) + " " + spark
1556
+ lines = append(lines, " "+memLine)
1557
+
1558
+ // Local stats
1559
+ root := m.workspaceRoot
1560
+ if m.ctx != nil {
1561
+ root = m.ctx.WorkspaceRoot
1562
+ }
1563
+ localApps := countLocalDirs(root, "apps")
1564
+ localMods := countLocalDirs(root, "packages", "core", "nx", "kit-", "cli")
1565
+ localThemes := countLocalDirs(root, "themes")
1566
+ localLine := mutedStyle.Render(padRight("Local", 9)) + " " +
1567
+ boldStyle.Render(fmt.Sprintf("%d", localApps)) + mutedStyle.Render(" apps · ") +
1568
+ boldStyle.Render(fmt.Sprintf("%d", localMods)) + mutedStyle.Render(" modules · ") +
1569
+ boldStyle.Render(fmt.Sprintf("%d", localThemes)) + mutedStyle.Render(" themes")
1570
+ lines = append(lines, " "+localLine)
1571
+
1572
+ // Registry stats
1573
+ totalStars := 0
1574
+ if m.catalog != nil {
1575
+ for _, e := range m.catalog.Entries {
1576
+ totalStars += e.Stars
1577
+ }
1578
+ }
1579
+ catCount := "loading…"
1580
+ cacheAge := ""
1581
+ if m.catalog != nil {
1582
+ catCount = fmt.Sprintf("%d pkgs", len(m.catalog.Entries))
1583
+ cacheAge = m.catalog.CacheAge
1584
+ }
1585
+ regLine := mutedStyle.Render(padRight("Registry", 9)) + " " +
1586
+ boldStyle.Render(fmt.Sprintf("%d", totalStars)) + mutedStyle.Render(" stars")
1587
+ lines = append(lines, " "+regLine)
1588
+
1589
+ // GitHub user
1590
+ ghUser := "(not set)"
1591
+ sshText := warnStyle.Render("HTTPS")
1592
+ if m.ctx != nil && m.ctx.Settings.GithubUser != nil {
1593
+ ghUser = *m.ctx.Settings.GithubUser
1594
+ sshText = mutedStyle.Render("SSH ok")
1595
+ }
1596
+ ghLine := mutedStyle.Render(padRight("GitHub", 9)) + " " + boldStyle.Render(ghUser) + " · " + sshText
1597
+ lines = append(lines, " "+ghLine)
1598
+
1599
+ // Catalog
1600
+ catalogLine := mutedStyle.Render(padRight("Catalog", 9)) + " " + boldStyle.Render(catCount)
1601
+ if cacheAge != "" {
1602
+ catalogLine += " " + mutedStyle.Render("("+cacheAge+")")
1603
+ }
1604
+ lines = append(lines, " "+catalogLine)
1605
+
1606
+ // Divider + tip
1607
+ lines = append(lines, " "+subtleStyle.Render(strings.Repeat("─", w-2)))
1608
+
1609
+ tips := []string{
1610
+ "Press 1, 2, or 3 to switch catalog tabs",
1611
+ "Press [s] to start the dev server",
1612
+ "Press [Enter] to install / uninstall a package",
1613
+ "Press [c] to change the install source",
1614
+ "Press [r] to refresh the package catalog",
1615
+ }
1616
+ tipIdx := (m.tickCount / 6) % len(tips)
1617
+ tipText := "Tip: " + tips[tipIdx]
1618
+ tipLine := subtleStyle.Render(truncate(tipText, w-4))
1619
+ lines = append(lines, " "+tipLine)
1620
+
1621
+ return strings.Join(lines, "\n")
1622
+ }
1623
+
1624
+ // ─────────────────────────────────────────────
1625
+ // Panel: Catalog
1626
+ // ─────────────────────────────────────────────
1627
+
1628
+ func (m TuiModel) renderCatalogPanel(w, h int, _ bool) string {
1629
+ var lines []string
1630
+
1631
+ // Tabs row
1632
+ var tabLine strings.Builder
1633
+ tabLine.WriteString(" ")
1634
+ if m.activeTab == 0 {
1635
+ tabLine.WriteString(tabActiveStyle.Render("0 Internal") + " ")
1636
+ }
1637
+ tabs := []string{"Apps", "Modules", "Themes"}
1638
+ for i, t := range tabs {
1639
+ tabIdx := i + 1
1640
+ num := fmt.Sprintf("%d", tabIdx)
1641
+ if tabIdx == m.activeTab {
1642
+ tabLine.WriteString(tabActiveStyle.Render(num + " " + t))
1643
+ } else {
1644
+ tabLine.WriteString(tabInactiveStyle.Render(num + " " + t))
1645
+ }
1646
+ if i < len(tabs)-1 {
1647
+ tabLine.WriteString(" ")
1648
+ }
1649
+ }
1650
+ sortLabel := " " + mutedStyle.Render("│") + " " + mutedStyle.Render("Sort: Updated")
1651
+ if m.loading {
1652
+ spinFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
1653
+ spin := lipgloss.NewStyle().Foreground(colorCyan).Render(spinFrames[m.tickCount%len(spinFrames)])
1654
+ sortLabel += " " + spin
1655
+ }
1656
+ tabLine.WriteString(sortLabel)
1657
+ lines = append(lines, tabLine.String())
1658
+
1659
+ // Column header (proportional to width)
1660
+ nameW := 26
1661
+ if w > 120 {
1662
+ nameW = 30
1663
+ }
1664
+ colHeader := " " + lipgloss.NewStyle().Foreground(colorSubtle).Render(
1665
+ padRight("", 2) + " " + // badge space
1666
+ padRight("NAME", nameW) + " " +
1667
+ padRight("SRC", 5) + " " +
1668
+ padRight("DIR", 5) + " " +
1669
+ padRight("SYNC", 6) + " " +
1670
+ padRight("PUBLISHER", 14) + " " +
1671
+ padLeft("STARS/AGE", 14),
1672
+ )
1673
+ lines = append(lines, colHeader)
1674
+ lines = append(lines, " "+subtleStyle.Render(strings.Repeat("─", max(w-2, 4))))
1675
+
1676
+ // Item list — reserve 4 rows for: header, col header, divider above detail, detail itself
1677
+ detailLines := 2
1678
+ fixedRows := 4 // tabrow + colheader + topdivider + bottomdivider
1679
+ listH := h - fixedRows - detailLines
1680
+ if listH < 1 {
1681
+ listH = 1
1682
+ }
1683
+
1684
+ items := m.getActiveItems()
1685
+
1686
+ // Clamp scroll
1687
+ scrollOff := m.scrollOffset
1688
+ if m.selectedIndex >= scrollOff+listH {
1689
+ scrollOff = m.selectedIndex - listH + 1
1690
+ }
1691
+ if scrollOff < 0 {
1692
+ scrollOff = 0
1693
+ }
1694
+
1695
+ if len(items) == 0 {
1696
+ if m.loading {
1697
+ lines = append(lines, " "+mutedStyle.Render("Loading packages…"))
1698
+ } else {
1699
+ lines = append(lines, " "+mutedStyle.Render("No packages found."))
1700
+ }
1701
+ } else {
1702
+ start := scrollOff
1703
+ end := start + listH
1704
+ if end > len(items) {
1705
+ end = len(items)
1706
+ }
1707
+ for i := start; i < end; i++ {
1708
+ item := items[i]
1709
+ line := m.renderCatalogRow(item, i == m.selectedIndex, w, nameW)
1710
+ lines = append(lines, line)
1711
+ }
1712
+ // Padding to fill listH
1713
+ for i := end - start; i < listH; i++ {
1714
+ lines = append(lines, "")
1715
+ }
1716
+ }
1717
+
1718
+ // Detail panel at bottom
1719
+ lines = append(lines, " "+subtleStyle.Render(strings.Repeat("─", max(w-2, 4))))
1720
+ detail := m.renderDetailRow(items)
1721
+ lines = append(lines, detail)
1722
+
1723
+ return strings.Join(lines, "\n")
1724
+ }
1725
+
1726
+ func max(a, b int) int {
1727
+ if a > b {
1728
+ return a
1729
+ }
1730
+ return b
1731
+ }
1732
+
1733
+ func (m TuiModel) renderCatalogRow(item bridge.CatalogEntry, selected bool, w, nameW int) string {
1734
+ badge := uninstalledBadge + " "
1735
+ if item.Installed {
1736
+ badge = installedBadge + " "
1737
+ }
1738
+
1739
+ name := truncate(item.ShortName, nameW)
1740
+
1741
+ // Source badge
1742
+ src := "NPM"
1743
+ srcColor := colorNpm
1744
+ if item.LocalSource {
1745
+ src = "LOC"
1746
+ srcColor = colorLocal
1747
+ } else if item.Installed && !item.LocalSource {
1748
+ src = "GIT"
1749
+ srcColor = colorGit
1750
+ }
1751
+ srcStr := lipgloss.NewStyle().Foreground(srcColor).Bold(true).Render(src)
1752
+ srcPad := strings.Repeat(" ", max(0, 5-lipgloss.Width(srcStr)))
1753
+
1754
+ dir := subtleStyle.Render("---")
1755
+ if item.Installed && !item.LocalSource {
1756
+ dir = errStyle.Bold(true).Render("MISS")
1757
+ } else if item.LocalSource {
1758
+ dir = accentStyle.Render("OK")
1759
+ }
1760
+ dirPad := strings.Repeat(" ", max(0, 5-lipgloss.Width(dir)))
1761
+
1762
+ sync := subtleStyle.Render("---")
1763
+ var syncParts []string
1764
+ if item.LocalSource {
1765
+ if ch, hasChanges := m.gitChangesMap[item.ShortName]; hasChanges {
1766
+ if ch.Added > 0 {
1767
+ syncParts = append(syncParts, accentStyle.Render(fmt.Sprintf("+%d", ch.Added)))
1768
+ }
1769
+ if ch.Modified > 0 {
1770
+ syncParts = append(syncParts, warnStyle.Render(fmt.Sprintf("~%d", ch.Modified)))
1771
+ }
1772
+ if ch.Deleted > 0 {
1773
+ syncParts = append(syncParts, errStyle.Render(fmt.Sprintf("-%d", ch.Deleted)))
1774
+ }
1775
+ }
1776
+ }
1777
+ if up, hasUpdate := m.updatesMap[item.ShortName]; hasUpdate {
1778
+ if up.LocalGit {
1779
+ countStr := ""
1780
+ if up.BehindCount > 0 {
1781
+ countStr = fmt.Sprintf("%d", up.BehindCount)
1782
+ }
1783
+ syncParts = append(syncParts, cyanStyle.Render("↓"+countStr))
1784
+ } else if up.Npm {
1785
+ syncParts = append(syncParts, accentStyle.Render("↑"))
1786
+ }
1787
+ }
1788
+ if len(syncParts) > 0 {
1789
+ sync = strings.Join(syncParts, " ")
1790
+ }
1791
+ syncPad := strings.Repeat(" ", max(0, 6-lipgloss.Width(sync)))
1792
+
1793
+ pub := item.Org
1794
+ if pub == "workspace" || pub == "" {
1795
+ pub = "owdproject"
1796
+ }
1797
+ pubFmt := padRight(truncate(pub, 14), 14)
1798
+
1799
+ age := formatCatalogAge(item.UpdatedAt)
1800
+ starsStr := ""
1801
+ if item.Stars > 0 {
1802
+ starsStr = fmt.Sprintf("★%d", item.Stars)
1803
+ }
1804
+
1805
+ metaStr := ""
1806
+ if starsStr != "" && age != "" {
1807
+ metaStr = warnStyle.Render(starsStr) + " " + subtleStyle.Render(age)
1808
+ } else if starsStr != "" {
1809
+ metaStr = warnStyle.Render(starsStr)
1810
+ } else if age != "" {
1811
+ metaStr = subtleStyle.Render(age)
1812
+ } else {
1813
+ metaStr = subtleStyle.Render("—")
1814
+ }
1815
+
1816
+ metaW := lipgloss.Width(metaStr)
1817
+ metaPad := ""
1818
+ if metaW < 14 {
1819
+ metaPad = strings.Repeat(" ", 14-metaW)
1820
+ }
1821
+ metaFmt := metaPad + metaStr
1822
+
1823
+ nameFmt := padRight(name, nameW)
1824
+
1825
+ row := fmt.Sprintf(" %s %s %s%s %s%s %s%s %s %s",
1826
+ badge,
1827
+ nameFmt,
1828
+ srcStr,
1829
+ srcPad,
1830
+ dir,
1831
+ dirPad,
1832
+ sync,
1833
+ syncPad,
1834
+ pubFmt,
1835
+ metaFmt,
1836
+ )
1837
+
1838
+ if selected {
1839
+ return selectedRowStyle.Render("▶ " + strings.TrimPrefix(row, " "))
1840
+ }
1841
+ return row
1842
+ }
1843
+
1844
+ func (m TuiModel) renderDetailRow(items []bridge.CatalogEntry) string {
1845
+ if len(items) == 0 || m.selectedIndex >= len(items) {
1846
+ return " " + mutedStyle.Render("Select a package for install preview")
1847
+ }
1848
+ item := items[m.selectedIndex]
1849
+
1850
+ status := mutedStyle.Render("Not Installed")
1851
+ if item.Installed {
1852
+ status = accentStyle.Render("Installed")
1853
+ if item.LocalSource {
1854
+ status += " " + lipgloss.NewStyle().Foreground(colorLocal).Render("(workspace)")
1855
+ }
1856
+ }
1857
+
1858
+ desc := item.Description
1859
+ if desc == "" {
1860
+ desc = "—"
1861
+ }
1862
+ desc = truncate(desc, 60)
1863
+
1864
+ org := item.Org
1865
+ if org == "workspace" || org == "" {
1866
+ org = "owdproject"
1867
+ }
1868
+
1869
+ stars := fmt.Sprintf("%d", item.Stars)
1870
+ kind := strings.ToUpper(item.Kind)
1871
+
1872
+ parts := []string{
1873
+ accentStyle.Render(item.ShortName),
1874
+ " " + mutedStyle.Render("Publisher:") + " " + boldStyle.Render(org),
1875
+ " " + mutedStyle.Render("Stars:") + " " + boldStyle.Render(stars),
1876
+ " " + mutedStyle.Render("Status:") + " " + status,
1877
+ " " + mutedStyle.Render("Type:") + " " + boldStyle.Render(kind),
1878
+ " " + mutedStyle.Render("—") + " " + mutedStyle.Render(desc),
1879
+ }
1880
+
1881
+ return " " + strings.Join(parts, " ")
1882
+ }
1883
+
1884
+ // ─────────────────────────────────────────────
1885
+ // Panel: Logs (right column, when server running)
1886
+ // ─────────────────────────────────────────────
1887
+
1888
+ func (m TuiModel) renderLogsPanel(w, h int) string {
1889
+ var lines []string
1890
+
1891
+ lines = append(lines, "")
1892
+
1893
+ // Server URLs
1894
+ if m.serverRunning {
1895
+ dot := accentStyle.Render("●")
1896
+ if !m.blink {
1897
+ dot = lipgloss.NewStyle().Foreground(lipgloss.Color("#2ea043")).Render("●")
1898
+ }
1899
+
1900
+ lines = append(lines, " "+dot+" "+lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render("Dev server started"))
1901
+ lines = append(lines, "")
1902
+ lines = append(lines, " "+accentStyle.Render("✓")+" "+mutedStyle.Render("Listening on http://localhost:3000"))
1903
+ lines = append(lines, "")
1904
+ }
1905
+
1906
+ // Log lines — show last N that fit
1907
+ logH := h - len(lines) - 1
1908
+ if logH < 1 {
1909
+ logH = 1
1910
+ }
1911
+ logStart := len(m.logLines) - logH
1912
+ if logStart < 0 {
1913
+ logStart = 0
1914
+ }
1915
+ for i := logStart; i < len(m.logLines); i++ {
1916
+ line := m.logLines[i]
1917
+ line = truncate(line, w)
1918
+ // Colorize log levels
1919
+ switch {
1920
+ case strings.Contains(line, "WARN") || strings.Contains(line, "Warning"):
1921
+ lines = append(lines, warnStyle.Render(" "+line))
1922
+ case strings.Contains(line, "ERROR") || strings.Contains(line, "Error") || strings.Contains(line, "failed"):
1923
+ lines = append(lines, errStyle.Render(" "+line))
1924
+ case strings.HasPrefix(line, ">>>"):
1925
+ lines = append(lines, accentStyle.Render(" "+line))
1926
+ case strings.Contains(line, "✓") || strings.Contains(line, "ready") || strings.Contains(line, "started"):
1927
+ lines = append(lines, lipgloss.NewStyle().Foreground(colorAccent).Render(" "+line))
1928
+ default:
1929
+ lines = append(lines, mutedStyle.Render(" "+line))
1930
+ }
1931
+ }
1932
+
1933
+ if len(m.logLines) == 0 {
1934
+ if m.serverRunning && ServerCmd == nil {
1935
+ lines = append(lines, mutedStyle.Render(" Logs unavailable (external server)"))
1936
+ } else {
1937
+ lines = append(lines, mutedStyle.Render(" Waiting for logs…"))
1938
+ }
1939
+ }
1940
+
1941
+ return strings.Join(lines, "\n")
1942
+ }
1943
+
1944
+ // ─────────────────────────────────────────────
1945
+ // Status Bar (borderless, Gemini-style)
1946
+ // ─────────────────────────────────────────────
1947
+
1948
+ func (m TuiModel) renderStatusBar(w int) string {
1949
+ // Line 1: current mode + status
1950
+ mode := ""
1951
+ switch m.activeTab {
1952
+ case 0:
1953
+ mode = "Apps"
1954
+ case 1:
1955
+ mode = "Modules"
1956
+ case 2:
1957
+ mode = "Themes"
1958
+ }
1959
+
1960
+ statusIcon := ""
1961
+ if m.serverRunning {
1962
+ if m.blink {
1963
+ statusIcon = lipgloss.NewStyle().Foreground(colorAccent).Render("● ")
1964
+ } else {
1965
+ statusIcon = lipgloss.NewStyle().Foreground(lipgloss.Color("#2ea043")).Render("● ")
1966
+ }
1967
+ }
1968
+
1969
+ line1Parts := []string{
1970
+ statusIcon + barKeyStyle.Render("Select packages") + barStyle.Render(" · "),
1971
+ barKeyStyle.Render("u") + barStyle.Render(" updates · "),
1972
+ barKeyStyle.Render("g") + barStyle.Render(" settings"),
1973
+ }
1974
+
1975
+ var serverShortcut string
1976
+ if m.serverRunning {
1977
+ serverShortcut = barKeyStyle.Render("x") + barStyle.Render(" stop server")
1978
+ } else {
1979
+ serverShortcut = barKeyStyle.Render("s") + barStyle.Render(" start server")
1980
+ }
1981
+
1982
+ if m.taskActive {
1983
+ spinFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
1984
+ spin := lipgloss.NewStyle().Foreground(colorCyan).Render(spinFrames[m.tickCount%len(spinFrames)])
1985
+ line1Parts = []string{spin + " " + barStyle.Render(m.statusMsg)}
1986
+ }
1987
+
1988
+ _ = mode
1989
+ line1 := barStyle.Width(w).Render(strings.Join(line1Parts, ""))
1990
+
1991
+ // Line 2: shortcuts
1992
+ sep := barSepStyle.Render(" │ ")
1993
+ shortcutParts := []string{
1994
+ barKeyStyle.Render("↑↓") + barStyle.Render(" move"),
1995
+ barKeyStyle.Render("Space") + barStyle.Render(" toggle"),
1996
+ barKeyStyle.Render("c") + barStyle.Render(" manage"),
1997
+ serverShortcut,
1998
+ barKeyStyle.Render("r") + barStyle.Render(" refresh"),
1999
+ barKeyStyle.Render("q") + barStyle.Render(" quit"),
2000
+ }
2001
+ line2 := barStyle.Width(w).Render(strings.Join(shortcutParts, sep))
2002
+
2003
+ return "\n\n" + line1 + "\n" + line2 + "\n"
2004
+ }
2005
+
2006
+ type InstallMethod struct {
2007
+ Name string
2008
+ Label string
2009
+ Desc string
2010
+ }
2011
+
2012
+ func (m TuiModel) getInstallMethods(pkg *bridge.CatalogEntry) []InstallMethod {
2013
+ var methods []InstallMethod
2014
+
2015
+ // 0. Local folder option if already present in workspace (pkg.LocalSource is true)
2016
+ if pkg.LocalSource {
2017
+ methods = append(methods, InstallMethod{
2018
+ Name: "local",
2019
+ Label: "Use Existing Local Folder",
2020
+ Desc: "Import/link the existing local folder in your workspace",
2021
+ })
2022
+ }
2023
+
2024
+ // 1. NPM Registry
2025
+ if pkg.SourcesMeta != nil && pkg.SourcesMeta.Npm != nil {
2026
+ methods = append(methods, InstallMethod{
2027
+ Name: "npm",
2028
+ Label: "NPM Registry",
2029
+ Desc: "Install from npm (default)",
2030
+ })
2031
+ }
2032
+
2033
+ // 2. Git options
2034
+ hasFork := false
2035
+ forkOwner := ""
2036
+ officialOwner := pkg.Org
2037
+ if officialOwner == "" || officialOwner == "workspace" {
2038
+ officialOwner = "owdproject"
2039
+ }
2040
+
2041
+ if pkg.SourcesMeta != nil && pkg.SourcesMeta.Github.Fork != nil && pkg.SourcesMeta.Github.Fork.Exists {
2042
+ // Only treat as "your fork" if GitHub confirms it's actually a fork (forked from another repo)
2043
+ if pkg.SourcesMeta.Github.Fork.IsFork {
2044
+ hasFork = true
2045
+ forkOwner = pkg.SourcesMeta.Github.Fork.Owner
2046
+ }
2047
+ }
2048
+
2049
+ if hasFork {
2050
+ methods = append(methods, InstallMethod{
2051
+ Name: "git-https",
2052
+ Label: "GIT HTTPS (your fork)",
2053
+ Desc: fmt.Sprintf("Clone via HTTPS from github.com/%s/%s", forkOwner, pkg.ShortName),
2054
+ })
2055
+ methods = append(methods, InstallMethod{
2056
+ Name: "git-ssh",
2057
+ Label: "GIT SSH (your fork)",
2058
+ Desc: fmt.Sprintf("Clone via SSH from github.com/%s/%s", forkOwner, pkg.ShortName),
2059
+ })
2060
+ } else {
2061
+ methods = append(methods, InstallMethod{
2062
+ Name: "git-https",
2063
+ Label: "GIT HTTPS",
2064
+ Desc: fmt.Sprintf("Clone via HTTPS from github.com/%s/%s", officialOwner, pkg.ShortName),
2065
+ })
2066
+ methods = append(methods, InstallMethod{
2067
+ Name: "git-ssh",
2068
+ Label: "GIT SSH",
2069
+ Desc: fmt.Sprintf("Clone via SSH from github.com/%s/%s", officialOwner, pkg.ShortName),
2070
+ })
2071
+ }
2072
+
2073
+ return methods
2074
+ }
2075
+
2076
+ // ─────────────────────────────────────────────
2077
+ // Modal
2078
+ // ─────────────────────────────────────────────
2079
+
2080
+ func (m TuiModel) renderModal() string {
2081
+ pkg := m.promptPkg
2082
+ var content strings.Builder
2083
+
2084
+ if m.activePrompt == PromptInstallMethod {
2085
+ content.WriteString(boldStyle.Render("Install source for ") + accentStyle.Render(pkg.ShortName) + "\n\n")
2086
+ methods := m.getInstallMethods(pkg)
2087
+ for i, mth := range methods {
2088
+ if i > 0 {
2089
+ if mth.Name != "local" && methods[i-1].Name == "local" {
2090
+ content.WriteString("\n " + subtleStyle.Render(strings.Repeat("─", 62)) + "\n\n")
2091
+ } else {
2092
+ content.WriteString("\n")
2093
+ }
2094
+ }
2095
+ var btn string
2096
+ if i == m.promptSel {
2097
+ btn = modalOptionActive.Render(mth.Label)
2098
+ } else {
2099
+ btn = modalOptionInactive.Render(mth.Label)
2100
+ }
2101
+ content.WriteString(" " + btn + "\n")
2102
+ content.WriteString(" " + mutedStyle.Render(mth.Desc) + "\n")
2103
+ }
2104
+ content.WriteString("\n" + subtleStyle.Render("↑↓ select Enter confirm Esc cancel"))
2105
+ return modalStyle.Width(72).Render(content.String())
2106
+ } else if m.activePrompt == PromptUninstallConfirm {
2107
+ content.WriteString(boldStyle.Render("Uninstall ") + errStyle.Render(pkg.ShortName) + boldStyle.Render("?") + "\n\n")
2108
+ opts := []string{"Yes, uninstall", "No, keep it"}
2109
+ for i, opt := range opts {
2110
+ if i == m.promptSel {
2111
+ bg := colorErr
2112
+ if i == 1 {
2113
+ bg = colorAccent
2114
+ }
2115
+ content.WriteString(lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" "+opt+" "))
2116
+ } else {
2117
+ content.WriteString(modalOptionInactive.Render(" " + opt + " "))
2118
+ }
2119
+ content.WriteString(" ")
2120
+ }
2121
+ content.WriteString("\n\n" + subtleStyle.Render("← → select Enter confirm Esc cancel"))
2122
+ } else if m.activePrompt == PromptForceReinstallConfirm {
2123
+ content.WriteString(boldStyle.Render("Force re-download ") + errStyle.Render(pkg.ShortName) + boldStyle.Render("?") + "\n")
2124
+ content.WriteString(warnStyle.Render("⚠ WARNING: This will delete the local directory and lose all modifications!") + "\n\n")
2125
+ opts := []string{"Yes, wipe & reinstall", "No, cancel"}
2126
+ for i, opt := range opts {
2127
+ if i == m.promptSel {
2128
+ bg := colorErr
2129
+ if i == 1 {
2130
+ bg = colorAccent
2131
+ }
2132
+ content.WriteString(lipgloss.NewStyle().Background(bg).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 2).Render(" "+opt+" "))
2133
+ } else {
2134
+ content.WriteString(modalOptionInactive.Render(" " + opt + " "))
2135
+ }
2136
+ content.WriteString(" ")
2137
+ }
2138
+ content.WriteString("\n\n" + subtleStyle.Render("← → select Enter confirm Esc cancel"))
2139
+ } else if m.activePrompt == PromptManagePackage {
2140
+ var leftCols []string
2141
+ leftCols = append(leftCols, boldStyle.Render("Package:")+" "+accentStyle.Render(pkg.ShortName))
2142
+ leftCols = append(leftCols, mutedStyle.Render("Type: ")+" "+boldStyle.Render(strings.ToUpper(pkg.Kind)))
2143
+
2144
+ status := mutedStyle.Render("Not Installed")
2145
+ if pkg.Installed {
2146
+ status = accentStyle.Render("Installed")
2147
+ if pkg.LocalSource {
2148
+ status += " " + lipgloss.NewStyle().Foreground(colorLocal).Render("(workspace)")
2149
+ }
2150
+ }
2151
+ leftCols = append(leftCols, mutedStyle.Render("Status: ")+" "+status)
2152
+
2153
+ src := "NPM"
2154
+ if pkg.LocalSource {
2155
+ src = "LOC (workspace)"
2156
+ } else if pkg.Installed && !pkg.LocalSource {
2157
+ src = "GIT"
2158
+ }
2159
+ leftCols = append(leftCols, mutedStyle.Render("Source: ")+" "+boldStyle.Render(src))
2160
+
2161
+ // Kits
2162
+ var kits []string
2163
+ for _, dep := range m.promptPkgDeps {
2164
+ if strings.HasPrefix(dep, "@owdproject/kit-") {
2165
+ shortKit := dep[strings.LastIndex(dep, "/")+1:]
2166
+ kits = append(kits, shortKit)
2167
+ }
2168
+ }
2169
+ kitsStr := "none"
2170
+ if len(kits) > 0 {
2171
+ var styledKits []string
2172
+ for _, k := range kits {
2173
+ styledKits = append(styledKits, accentStyle.Render(k))
2174
+ }
2175
+ kitsStr = strings.Join(styledKits, ", ")
2176
+ }
2177
+ leftCols = append(leftCols, mutedStyle.Render("Kits: ")+" "+kitsStr)
2178
+
2179
+ gitStatus := "—"
2180
+ if pkg.LocalSource {
2181
+ kindDir := ""
2182
+ switch pkg.Kind {
2183
+ case "app":
2184
+ kindDir = "apps"
2185
+ case "module":
2186
+ kindDir = "packages"
2187
+ case "theme":
2188
+ kindDir = "themes"
2189
+ }
2190
+ if kindDir != "" {
2191
+ pkgPath := filepath.Join(m.workspaceRoot, kindDir, pkg.ShortName)
2192
+ gitDir := filepath.Join(pkgPath, ".git")
2193
+ if _, err := os.Stat(gitDir); err == nil {
2194
+ branch := gitBranch(pkgPath)
2195
+ changes := gitChanges(pkgPath)
2196
+ gitStatus = fmt.Sprintf("branch: %s (%s)", branch, changes)
2197
+ }
2198
+ }
2199
+ }
2200
+ leftCols = append(leftCols, mutedStyle.Render("Git: ")+" "+gitStatus)
2201
+
2202
+ // NPM & GitHub Links
2203
+ npmLink := fmt.Sprintf("https://www.npmjs.com/package/%s", pkg.Name)
2204
+ leftCols = append(leftCols, mutedStyle.Render("NPM: ")+" "+cyanStyle.Render(npmLink))
2205
+
2206
+ ghLink := fmt.Sprintf("https://github.com/owdproject/%s", pkg.ShortName)
2207
+ if pkg.HtmlUrl != nil && *pkg.HtmlUrl != "" {
2208
+ ghLink = *pkg.HtmlUrl
2209
+ }
2210
+ leftCols = append(leftCols, mutedStyle.Render("GitHub: ")+" "+cyanStyle.Render(ghLink))
2211
+
2212
+ // Deps (excluding kits)
2213
+ var otherDeps []string
2214
+ for _, d := range m.promptPkgDeps {
2215
+ if !strings.HasPrefix(d, "@owdproject/kit-") {
2216
+ s := d
2217
+ if idx := strings.LastIndex(d, "/"); idx >= 0 {
2218
+ s = d[idx+1:]
2219
+ }
2220
+ otherDeps = append(otherDeps, s)
2221
+ }
2222
+ }
2223
+ depsStr := "none"
2224
+ if len(otherDeps) > 0 {
2225
+ depsStr = strings.Join(otherDeps, ", ")
2226
+ }
2227
+ leftCols = append(leftCols, mutedStyle.Render("Deps: ")+" "+boldStyle.Render(depsStr))
2228
+
2229
+ leftPanel := strings.Join(leftCols, "\n")
2230
+
2231
+ var rightCols []string
2232
+ rightCols = append(rightCols, boldStyle.Render("Management Actions:")+"\n")
2233
+
2234
+ actions := []string{
2235
+ "Update package",
2236
+ "Switch to NPM registry",
2237
+ "Switch to Git (HTTPS)",
2238
+ "Switch to Git (SSH)",
2239
+ "Force Re-download (wipe changes)",
2240
+ "Back to catalog",
2241
+ }
2242
+
2243
+ for i, act := range actions {
2244
+ if i == m.promptSel {
2245
+ rightCols = append(rightCols, modalOptionActive.Render(fmt.Sprintf(" %s ", act)))
2246
+ } else {
2247
+ rightCols = append(rightCols, modalOptionInactive.Render(fmt.Sprintf(" %s ", act)))
2248
+ }
2249
+ }
2250
+
2251
+ rightPanel := strings.Join(rightCols, "\n")
2252
+
2253
+ splitLayout := lipgloss.JoinHorizontal(lipgloss.Top,
2254
+ lipgloss.NewStyle().Width(65).Render(leftPanel),
2255
+ lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).
2256
+ BorderForeground(colorDim).
2257
+ PaddingLeft(3).
2258
+ Render(rightPanel),
2259
+ )
2260
+
2261
+ content.WriteString(splitLayout)
2262
+ content.WriteString("\n\n" + subtleStyle.Render("↑↓ select Enter confirm Esc close"))
2263
+
2264
+ return modalStyle.Width(110).Render(content.String())
2265
+ } else if m.activePrompt == PromptSettings {
2266
+ content.WriteString(boldStyle.Render("Control Panel Settings") + "\n\n")
2267
+
2268
+ // 1. Install Mode
2269
+ modeLabel := "1. Install Mode"
2270
+ var modeVal string
2271
+ if m.settingsInstallMode == "npm" {
2272
+ modeVal = modalOptionActive.Render(" NPM registry ") + " " + modalOptionInactive.Render(" workspace ")
2273
+ } else {
2274
+ modeVal = modalOptionInactive.Render(" NPM registry ") + " " + modalOptionActive.Render(" workspace ")
2275
+ }
2276
+ if m.settingsSel == 0 {
2277
+ content.WriteString(accentStyle.Render("▶ ") + boldStyle.Render(modeLabel) + "\n " + modeVal + "\n")
2278
+ } else {
2279
+ content.WriteString(" " + boldStyle.Render(modeLabel) + "\n " + modeVal + "\n")
2280
+ }
2281
+ content.WriteString("\n")
2282
+
2283
+ // 2. Catalog Sort
2284
+ sortLabel := "2. Catalog Sort"
2285
+ var sortVal strings.Builder
2286
+ sortModes := []string{"updated", "name", "stars", "installed"}
2287
+ for _, sm := range sortModes {
2288
+ if sm == m.settingsCatalogSort {
2289
+ sortVal.WriteString(modalOptionActive.Render(" "+sm+" ") + " ")
2290
+ } else {
2291
+ sortVal.WriteString(modalOptionInactive.Render(" "+sm+" ") + " ")
2292
+ }
2293
+ }
2294
+ if m.settingsSel == 1 {
2295
+ content.WriteString(accentStyle.Render("▶ ") + boldStyle.Render(sortLabel) + "\n " + sortVal.String() + "\n")
2296
+ } else {
2297
+ content.WriteString(" " + boldStyle.Render(sortLabel) + "\n " + sortVal.String() + "\n")
2298
+ }
2299
+ content.WriteString("\n")
2300
+
2301
+ // 3. Trusted Orgs (Authors)
2302
+ orgsLabel := "3. Trusted Orgs"
2303
+ var orgsVal string
2304
+ if m.ctx != nil && len(m.ctx.Settings.GithubOrgs) > 0 {
2305
+ orgsVal = strings.Join(m.ctx.Settings.GithubOrgs, ", ")
2306
+ } else {
2307
+ orgsVal = "owdproject, atproto-os" // default/fallback
2308
+ }
2309
+ orgsValText := boldStyle.Render(orgsVal) + " " + subtleStyle.Render("(Read-only. Edit settings.json)")
2310
+ if m.settingsSel == 2 {
2311
+ content.WriteString(accentStyle.Render("▶ ") + boldStyle.Render(orgsLabel) + "\n " + orgsValText + "\n")
2312
+ } else {
2313
+ content.WriteString(" " + boldStyle.Render(orgsLabel) + "\n " + orgsValText + "\n")
2314
+ }
2315
+ content.WriteString("\n")
2316
+
2317
+ // 4. GitHub User
2318
+ ghLabel := "4. GitHub User"
2319
+ ghUser := "(not set)"
2320
+ if m.ctx != nil && m.ctx.Settings.GithubUser != nil && *m.ctx.Settings.GithubUser != "" {
2321
+ ghUser = *m.ctx.Settings.GithubUser
2322
+ }
2323
+ ghVal := boldStyle.Render(ghUser) + " " + subtleStyle.Render("(Read-only. Edit settings.json)")
2324
+ if m.settingsSel == 3 {
2325
+ content.WriteString(accentStyle.Render("▶ ") + boldStyle.Render(ghLabel) + "\n " + ghVal + "\n")
2326
+ } else {
2327
+ content.WriteString(" " + boldStyle.Render(ghLabel) + "\n " + ghVal + "\n")
2328
+ }
2329
+ content.WriteString("\n\n")
2330
+
2331
+ // Buttons Row: Save and Cancel
2332
+ var saveBtn, cancelBtn string
2333
+ if m.settingsSel == 4 {
2334
+ saveBtn = lipgloss.NewStyle().Background(colorAccent).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 3).Render(" SAVE ")
2335
+ } else {
2336
+ saveBtn = lipgloss.NewStyle().Background(colorDim).Foreground(colorWhite).Padding(0, 3).Render(" SAVE ")
2337
+ }
2338
+
2339
+ if m.settingsSel == 5 {
2340
+ cancelBtn = lipgloss.NewStyle().Background(colorErr).Foreground(lipgloss.Color("#000000")).Bold(true).Padding(0, 3).Render(" CANCEL ")
2341
+ } else {
2342
+ cancelBtn = lipgloss.NewStyle().Background(colorDim).Foreground(colorWhite).Padding(0, 3).Render(" CANCEL ")
2343
+ }
2344
+
2345
+ content.WriteString(" " + saveBtn + " " + cancelBtn + "\n\n")
2346
+ content.WriteString(subtleStyle.Render("↑↓ select item ←→ toggle/buttons Enter confirm Esc exit"))
2347
+ return modalStyle.Width(72).Render(content.String())
2348
+ }
2349
+
2350
+ return modalStyle.Render(content.String())
2351
+ }
2352
+
2353
+ // ─────────────────────────────────────────────
2354
+ // Background tasks
2355
+ // ─────────────────────────────────────────────
2356
+
2357
+ func runProcessAndStreamLogs(root, command string, args []string, p *tea.Program) {
2358
+ cmd := exec.Command(command, args...)
2359
+ cmd.Dir = root
2360
+
2361
+ stdout, err := cmd.StdoutPipe()
2362
+ if err != nil {
2363
+ p.Send(taskFinishedMsg{Success: false, Err: err})
2364
+ return
2365
+ }
2366
+ cmd.Stderr = cmd.Stdout
2367
+
2368
+ if err := cmd.Start(); err != nil {
2369
+ p.Send(taskFinishedMsg{Success: false, Err: err})
2370
+ return
2371
+ }
2372
+
2373
+ reader := bufio.NewReader(stdout)
2374
+ for {
2375
+ line, err := reader.ReadString('\n')
2376
+ if err != nil {
2377
+ if err != io.EOF {
2378
+ // ignore partial read errors
2379
+ }
2380
+ break
2381
+ }
2382
+ p.Send(logLineMsg(strings.TrimRight(line, "\r\n")))
2383
+ }
2384
+
2385
+ err = cmd.Wait()
2386
+ p.Send(taskFinishedMsg{Success: err == nil, Err: err})
2387
+ }
2388
+
2389
+ func (m *TuiModel) RunInstallTask(pkgName string, method string, p *tea.Program) {
2390
+ go func() {
2391
+ shortName := pkgName
2392
+ if idx := strings.LastIndex(pkgName, "/"); idx >= 0 {
2393
+ shortName = pkgName[idx+1:]
2394
+ }
2395
+
2396
+ desktopJs := filepath.Join(m.workspaceRoot, "packages", "cli", "bin", "desktop.js")
2397
+
2398
+ // Build arguments
2399
+ args := []string{desktopJs, "add", shortName}
2400
+ if method == "npm" {
2401
+ args = append(args, "--npm")
2402
+ } else if method == "local" {
2403
+ args = append(args, "--dev")
2404
+ } else {
2405
+ // Find org/owner
2406
+ owner := "owdproject"
2407
+ if m.ctx != nil && m.ctx.Settings.GithubUser != nil && *m.ctx.Settings.GithubUser != "" {
2408
+ owner = *m.ctx.Settings.GithubUser
2409
+ } else if m.catalog != nil {
2410
+ for _, e := range m.catalog.Entries {
2411
+ if e.Name == pkgName && e.Org != "" {
2412
+ owner = e.Org
2413
+ break
2414
+ }
2415
+ }
2416
+ }
2417
+
2418
+ var fromVal string
2419
+ if method == "git-ssh" {
2420
+ fromVal = fmt.Sprintf("git@github.com:%s/%s.git", owner, shortName)
2421
+ } else {
2422
+ fromVal = fmt.Sprintf("https://github.com/%s/%s.git", owner, shortName)
2423
+ }
2424
+ args = append(args, "--from", fromVal)
2425
+ }
2426
+
2427
+ p.Send(logLineMsg(fmt.Sprintf(">>> Executing: node desktop.js add %s", shortName)))
2428
+ runProcessAndStreamLogs(m.workspaceRoot, "node", args, p)
2429
+
2430
+ // Check if it's a theme and resolve dependencies!
2431
+ isTheme := strings.HasPrefix(shortName, "theme-")
2432
+ if isTheme {
2433
+ p.Send(logLineMsg(">>> Resolving theme dependencies…"))
2434
+ deps, err := getThemeDependencies(m.workspaceRoot, shortName)
2435
+ if err == nil {
2436
+ for _, dep := range deps {
2437
+ // Check if already installed in context
2438
+ installed := false
2439
+ if m.ctx != nil {
2440
+ for _, d := range m.ctx.Deps {
2441
+ if d == dep {
2442
+ installed = true
2443
+ break
2444
+ }
2445
+ }
2446
+ }
2447
+
2448
+ if !installed {
2449
+ depShort := dep
2450
+ if idx := strings.LastIndex(dep, "/"); idx >= 0 {
2451
+ depShort = dep[idx+1:]
2452
+ }
2453
+
2454
+ p.Send(logLineMsg(fmt.Sprintf(">>> Installing theme dependency: %s…", depShort)))
2455
+ depArgs := []string{desktopJs, "add", depShort}
2456
+ if method == "npm" {
2457
+ depArgs = append(depArgs, "--npm")
2458
+ } else {
2459
+ depOwner := "owdproject"
2460
+ if m.ctx != nil && m.ctx.Settings.GithubUser != nil && *m.ctx.Settings.GithubUser != "" {
2461
+ depOwner = *m.ctx.Settings.GithubUser
2462
+ }
2463
+
2464
+ var fromVal string
2465
+ if method == "git-ssh" {
2466
+ fromVal = fmt.Sprintf("git@github.com:%s/%s.git", depOwner, depShort)
2467
+ } else {
2468
+ fromVal = fmt.Sprintf("https://github.com/%s/%s.git", depOwner, depShort)
2469
+ }
2470
+ depArgs = append(depArgs, "--from", fromVal)
2471
+ }
2472
+ runProcessAndStreamLogs(m.workspaceRoot, "node", depArgs, p)
2473
+ }
2474
+ }
2475
+ }
2476
+ }
2477
+
2478
+ // Rebuild stubs
2479
+ p.Send(logLineMsg(">>> Rebuilding stubs…"))
2480
+ runProcessAndStreamLogs(m.workspaceRoot, "pnpm", []string{"run", "prepare:modules"}, p)
2481
+
2482
+ p.Send(taskFinishedMsg{Success: true})
2483
+ }()
2484
+ }
2485
+
2486
+ func (m *TuiModel) RunUninstallTask(p *tea.Program) {
2487
+ go func() {
2488
+ p.Send(logLineMsg(">>> Running pnpm install (cleanup)…"))
2489
+ runProcessAndStreamLogs(m.workspaceRoot, "pnpm", []string{"install"}, p)
2490
+ p.Send(logLineMsg(">>> Preparing workspace modules…"))
2491
+ runProcessAndStreamLogs(m.workspaceRoot, "pnpm", []string{"run", "prepare:modules"}, p)
2492
+ p.Send(taskFinishedMsg{Success: true})
2493
+ }()
2494
+ }
2495
+
2496
+ func (m *TuiModel) RunServeTask(p *tea.Program) {
2497
+ go func() {
2498
+ if m.ctx == nil {
2499
+ p.Send(serverStatusMsg{Running: false})
2500
+ return
2501
+ }
2502
+
2503
+ _ = os.MkdirAll(m.ctx.Paths.MetaDir, 0755)
2504
+
2505
+ logPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.log")
2506
+ logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
2507
+ if err != nil {
2508
+ p.Send(logLineMsg(fmt.Sprintf(">>> Failed to open log file: %v", err)))
2509
+ p.Send(serverStatusMsg{Running: false})
2510
+ return
2511
+ }
2512
+ defer logFile.Close()
2513
+
2514
+ logFile.WriteString(">>> Starting Nuxt dev server (pnpm run dev)…\n")
2515
+
2516
+ cmd := exec.Command("pnpm", "run", "dev")
2517
+ if m.ctx.Paths.IsPlayground {
2518
+ cmd.Dir = m.ctx.Paths.PackageDir
2519
+ } else {
2520
+ cmd.Dir = m.workspaceRoot
2521
+ }
2522
+
2523
+ cmd.Env = append(os.Environ(), fmt.Sprintf("PORT=%d", m.ctx.Settings.DevPort))
2524
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
2525
+ cmd.Stdout = logFile
2526
+ cmd.Stderr = logFile
2527
+ ServerCmd = cmd
2528
+
2529
+ if err := cmd.Start(); err != nil {
2530
+ logFile.WriteString(fmt.Sprintf(">>> Server failed to start: %v\n", err))
2531
+ p.Send(serverStatusMsg{Running: false})
2532
+ return
2533
+ }
2534
+
2535
+ // Write PID file
2536
+ pidPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.pid")
2537
+ _ = os.WriteFile(pidPath, []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644)
2538
+
2539
+ p.Send(serverStatusMsg{Running: true})
2540
+
2541
+ cmd.Wait()
2542
+ logFile.WriteString(">>> Dev server exited.\n")
2543
+ p.Send(serverStatusMsg{Running: false})
2544
+ ServerCmd = nil
2545
+ }()
2546
+ }
2547
+
2548
+ func (m *TuiModel) StopServeTask(p *tea.Program) {
2549
+ go func() {
2550
+ if m.ctx == nil {
2551
+ p.Send(serverStatusMsg{Running: false})
2552
+ return
2553
+ }
2554
+
2555
+ if ServerCmd != nil && ServerCmd.Process != nil {
2556
+ _ = syscall.Kill(-ServerCmd.Process.Pid, syscall.SIGKILL)
2557
+ ServerCmd = nil
2558
+ }
2559
+
2560
+ pidPath := filepath.Join(m.ctx.Paths.MetaDir, "dev.pid")
2561
+ if data, err := os.ReadFile(pidPath); err == nil {
2562
+ var pid int
2563
+ if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &pid); err == nil {
2564
+ _ = syscall.Kill(-pid, syscall.SIGKILL)
2565
+ _ = syscall.Kill(pid, syscall.SIGKILL)
2566
+ }
2567
+ _ = os.Remove(pidPath)
2568
+ }
2569
+
2570
+ _ = exec.Command("pkill", "-9", "-f", "nuxt.mjs").Run()
2571
+ _ = exec.Command("pkill", "-9", "-f", "nuxt dev").Run()
2572
+ _ = exec.Command("pkill", "-9", "-f", "nx run desktop:serve").Run()
2573
+
2574
+ p.Send(serverStatusMsg{Running: false})
2575
+ }()
2576
+ }
2577
+
2578
+ func StartLogTailer(logPath string, p *tea.Program) {
2579
+ go func() {
2580
+ var lastSize int64 = 0
2581
+
2582
+ // Check if file exists and get its initial size
2583
+ if info, err := os.Stat(logPath); err == nil {
2584
+ lastSize = info.Size()
2585
+ // Seek back up to 10000 bytes (~100 lines) to show initial history
2586
+ offset := lastSize - 10000
2587
+ if offset < 0 {
2588
+ offset = 0
2589
+ }
2590
+
2591
+ file, err := os.Open(logPath)
2592
+ if err == nil {
2593
+ _, _ = file.Seek(offset, 0)
2594
+ reader := bufio.NewReader(file)
2595
+ if offset > 0 {
2596
+ _, _ = reader.ReadString('\n') // discard first potentially cut line
2597
+ }
2598
+ for {
2599
+ line, err := reader.ReadString('\n')
2600
+ if err != nil {
2601
+ break
2602
+ }
2603
+ p.Send(logLineMsg(strings.TrimRight(line, "\r\n")))
2604
+ }
2605
+ file.Close()
2606
+ }
2607
+ }
2608
+
2609
+ for {
2610
+ time.Sleep(250 * time.Millisecond)
2611
+
2612
+ info, err := os.Stat(logPath)
2613
+ if err != nil {
2614
+ continue
2615
+ }
2616
+
2617
+ if info.Size() < lastSize {
2618
+ // File was truncated/cleared
2619
+ lastSize = info.Size()
2620
+ p.Send(clearLogsMsg{})
2621
+ continue
2622
+ }
2623
+
2624
+ if info.Size() > lastSize {
2625
+ file, err := os.Open(logPath)
2626
+ if err != nil {
2627
+ continue
2628
+ }
2629
+ _, err = file.Seek(lastSize, 0)
2630
+ if err != nil {
2631
+ file.Close()
2632
+ continue
2633
+ }
2634
+
2635
+ reader := bufio.NewReader(file)
2636
+ for {
2637
+ line, err := reader.ReadString('\n')
2638
+ if err != nil {
2639
+ break
2640
+ }
2641
+ p.Send(logLineMsg(strings.TrimRight(line, "\r\n")))
2642
+ }
2643
+ lastSize = info.Size()
2644
+ file.Close()
2645
+ }
2646
+ }
2647
+ }()
2648
+ }
2649
+
2650
+ // ─────────────────────────────────────────────
2651
+ // Git & Updates Helpers
2652
+ // ─────────────────────────────────────────────
2653
+
2654
+ func runGitStatus(dir string) (added, modified, deleted int, err error) {
2655
+ cmd := exec.Command("git", "status", "--porcelain")
2656
+ cmd.Dir = dir
2657
+ out, err := cmd.Output()
2658
+ if err != nil {
2659
+ return 0, 0, 0, err
2660
+ }
2661
+ lines := strings.Split(string(out), "\n")
2662
+ for _, line := range lines {
2663
+ if len(line) < 3 {
2664
+ continue
2665
+ }
2666
+ status := line[:2]
2667
+ if strings.HasPrefix(status, "??") {
2668
+ added++
2669
+ } else if strings.Contains(status, "D") {
2670
+ deleted++
2671
+ } else if strings.Contains(status, "M") || strings.Contains(status, "A") || strings.Contains(status, "R") || strings.Contains(status, "C") {
2672
+ modified++
2673
+ }
2674
+ }
2675
+ return added, modified, deleted, nil
2676
+ }
2677
+
2678
+ func runGitStatusForSubdir(rootDir, subdir string) (added, modified, deleted int, err error) {
2679
+ cmd := exec.Command("git", "status", "--porcelain", subdir)
2680
+ cmd.Dir = rootDir
2681
+ out, err := cmd.Output()
2682
+ if err != nil {
2683
+ return 0, 0, 0, err
2684
+ }
2685
+ lines := strings.Split(string(out), "\n")
2686
+ for _, line := range lines {
2687
+ if len(line) < 3 {
2688
+ continue
2689
+ }
2690
+ status := line[:2]
2691
+ if strings.HasPrefix(status, "??") {
2692
+ added++
2693
+ } else if strings.Contains(status, "D") {
2694
+ deleted++
2695
+ } else if strings.Contains(status, "M") || strings.Contains(status, "A") || strings.Contains(status, "R") || strings.Contains(status, "C") {
2696
+ modified++
2697
+ }
2698
+ }
2699
+ return added, modified, deleted, nil
2700
+ }
2701
+
2702
+ func runGitBehindCheck(dir string) (int, error) {
2703
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
2704
+ defer cancel()
2705
+
2706
+ cmdFetch := exec.CommandContext(ctx, "git", "fetch")
2707
+ cmdFetch.Dir = dir
2708
+ _ = cmdFetch.Run() // ignore fetch errors
2709
+
2710
+ cmdRev := exec.Command("git", "rev-list", "--count", "HEAD..@{u}")
2711
+ cmdRev.Dir = dir
2712
+ out, err := cmdRev.Output()
2713
+ if err != nil {
2714
+ // Try with origin/branch
2715
+ cmdBranch := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
2716
+ cmdBranch.Dir = dir
2717
+ branchOut, errBranch := cmdBranch.Output()
2718
+ if errBranch != nil {
2719
+ return 0, errBranch
2720
+ }
2721
+ branch := strings.TrimSpace(string(branchOut))
2722
+ if branch == "" || branch == "HEAD" {
2723
+ return 0, err
2724
+ }
2725
+ cmdRev = exec.Command("git", "rev-list", "--count", fmt.Sprintf("HEAD..origin/%s", branch))
2726
+ cmdRev.Dir = dir
2727
+ out, err = cmdRev.Output()
2728
+ if err != nil {
2729
+ return 0, err
2730
+ }
2731
+ }
2732
+
2733
+ var behind int
2734
+ _, err = fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &behind)
2735
+ return behind, err
2736
+ }
2737
+
2738
+ func fetchLatestNpmVersion(pkgName string) (string, error) {
2739
+ client := &http.Client{Timeout: 3 * time.Second}
2740
+ resp, err := client.Get(fmt.Sprintf("https://registry.npmjs.org/%s/latest", url.PathEscape(pkgName)))
2741
+ if err != nil {
2742
+ return "", err
2743
+ }
2744
+ defer resp.Body.Close()
2745
+ if resp.StatusCode != http.StatusOK {
2746
+ return "", fmt.Errorf("HTTP %d", resp.StatusCode)
2747
+ }
2748
+ var data struct {
2749
+ Version string `json:"version"`
2750
+ }
2751
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
2752
+ return "", err
2753
+ }
2754
+ return data.Version, nil
2755
+ }
2756
+
2757
+ func getLocalVersion(workspaceRoot string, entry bridge.CatalogEntry) string {
2758
+ short := entry.ShortName
2759
+ kindDir := ""
2760
+ switch entry.Kind {
2761
+ case "app":
2762
+ kindDir = "apps"
2763
+ case "module":
2764
+ kindDir = "packages"
2765
+ case "theme":
2766
+ kindDir = "themes"
2767
+ }
2768
+ var paths []string
2769
+ if kindDir != "" {
2770
+ paths = append(paths, filepath.Join(workspaceRoot, kindDir, short, "package.json"))
2771
+ }
2772
+ paths = append(paths, filepath.Join(workspaceRoot, "node_modules", entry.Name, "package.json"))
2773
+
2774
+ for _, p := range paths {
2775
+ if data, err := os.ReadFile(p); err == nil {
2776
+ var pkg struct {
2777
+ Version string `json:"version"`
2778
+ }
2779
+ if err := json.Unmarshal(data, &pkg); err == nil {
2780
+ return pkg.Version
2781
+ }
2782
+ }
2783
+ }
2784
+ return ""
2785
+ }
2786
+
2787
+ func semverCompare(v1, v2 string) int {
2788
+ v1 = strings.TrimPrefix(v1, "v")
2789
+ v2 = strings.TrimPrefix(v2, "v")
2790
+ parts1 := strings.Split(v1, ".")
2791
+ parts2 := strings.Split(v2, ".")
2792
+ for i := 0; i < len(parts1) && i < len(parts2); i++ {
2793
+ var n1, n2 int
2794
+ fmt.Sscanf(parts1[i], "%d", &n1)
2795
+ fmt.Sscanf(parts2[i], "%d", &n2)
2796
+ if n1 != n2 {
2797
+ return n1 - n2
2798
+ }
2799
+ }
2800
+ return len(parts1) - len(parts2)
2801
+ }
2802
+
2803
+ func (m TuiModel) checkForUpdatesCmd() tea.Cmd {
2804
+ return func() tea.Msg {
2805
+ gitChangesMap := make(map[string]GitChanges)
2806
+ updatesMap := make(map[string]UpdateInfo)
2807
+
2808
+ if m.catalog == nil {
2809
+ return updatesLoadedMsg{GitChanges: gitChangesMap, Updates: updatesMap}
2810
+ }
2811
+
2812
+ type result struct {
2813
+ shortName string
2814
+ changes *GitChanges
2815
+ update *UpdateInfo
2816
+ }
2817
+
2818
+ resultsChan := make(chan result, len(m.catalog.Entries))
2819
+ sem := make(chan struct{}, 10) // Limit concurrency
2820
+
2821
+ for _, e := range m.catalog.Entries {
2822
+ sem <- struct{}{}
2823
+ go func(entry bridge.CatalogEntry) {
2824
+ defer func() { <-sem }()
2825
+
2826
+ res := result{shortName: entry.ShortName}
2827
+
2828
+ // 1. Check local changes & upstream behind counts
2829
+ short := entry.ShortName
2830
+ kindDir := ""
2831
+ switch entry.Kind {
2832
+ case "app":
2833
+ kindDir = "apps"
2834
+ case "module":
2835
+ kindDir = "packages"
2836
+ case "theme":
2837
+ kindDir = "themes"
2838
+ }
2839
+
2840
+ if kindDir != "" {
2841
+ pkgPath := filepath.Join(m.workspaceRoot, kindDir, short)
2842
+ gitDir := filepath.Join(pkgPath, ".git")
2843
+ if _, err := os.Stat(gitDir); err == nil {
2844
+ added, modified, deleted, err := runGitStatus(pkgPath)
2845
+ if err == nil && (added > 0 || modified > 0 || deleted > 0) {
2846
+ res.changes = &GitChanges{Added: added, Modified: modified, Deleted: deleted}
2847
+ }
2848
+
2849
+ behind, err := runGitBehindCheck(pkgPath)
2850
+ if err == nil && behind > 0 {
2851
+ res.update = &UpdateInfo{LocalGit: true, BehindCount: behind}
2852
+ }
2853
+ } else {
2854
+ // Fallback: Check if the monorepo itself tracks changes for this directory
2855
+ parentGitDir := filepath.Join(m.workspaceRoot, ".git")
2856
+ if _, err := os.Stat(parentGitDir); err == nil {
2857
+ relPath := filepath.Join(kindDir, short)
2858
+ added, modified, deleted, err := runGitStatusForSubdir(m.workspaceRoot, relPath)
2859
+ if err == nil && (added > 0 || modified > 0 || deleted > 0) {
2860
+ res.changes = &GitChanges{Added: added, Modified: modified, Deleted: deleted}
2861
+ }
2862
+ }
2863
+ }
2864
+ }
2865
+
2866
+ // 2. Check NPM updates
2867
+ if entry.Installed && !entry.LocalSource {
2868
+ localVer := getLocalVersion(m.workspaceRoot, entry)
2869
+ if localVer != "" {
2870
+ latestVer, err := fetchLatestNpmVersion(entry.Name)
2871
+ if err == nil && semverCompare(localVer, latestVer) < 0 {
2872
+ res.update = &UpdateInfo{Npm: true}
2873
+ }
2874
+ }
2875
+ }
2876
+
2877
+ resultsChan <- res
2878
+ }(e)
2879
+ }
2880
+
2881
+ for i := 0; i < len(m.catalog.Entries); i++ {
2882
+ res := <-resultsChan
2883
+ if res.changes != nil {
2884
+ gitChangesMap[res.shortName] = *res.changes
2885
+ }
2886
+ if res.update != nil {
2887
+ updatesMap[res.shortName] = *res.update
2888
+ }
2889
+ }
2890
+
2891
+ return updatesLoadedMsg{GitChanges: gitChangesMap, Updates: updatesMap}
2892
+ }
2893
+ }
2894
+
2895
+ // ─────────────────────────────────────────────
2896
+ // Theme Dependencies & Package Metadata Helpers
2897
+ // ─────────────────────────────────────────────
2898
+
2899
+ func getThemeDependencies(workspaceRoot, themeShortName string) ([]string, error) {
2900
+ var pathsToCheck []string
2901
+ pathsToCheck = append(pathsToCheck, filepath.Join(workspaceRoot, "themes", themeShortName, "package.json"))
2902
+ pathsToCheck = append(pathsToCheck, filepath.Join(workspaceRoot, "node_modules", "@owdproject", themeShortName, "package.json"))
2903
+
2904
+ var data []byte
2905
+ var err error
2906
+ for _, p := range pathsToCheck {
2907
+ data, err = os.ReadFile(p)
2908
+ if err == nil {
2909
+ break
2910
+ }
2911
+ }
2912
+
2913
+ if err != nil {
2914
+ return nil, err
2915
+ }
2916
+
2917
+ var pkg struct {
2918
+ Dependencies map[string]string `json:"dependencies"`
2919
+ DevDependencies map[string]string `json:"devDependencies"`
2920
+ }
2921
+ if err := json.Unmarshal(data, &pkg); err != nil {
2922
+ return nil, err
2923
+ }
2924
+
2925
+ var deps []string
2926
+ seen := make(map[string]bool)
2927
+ for name := range pkg.Dependencies {
2928
+ if strings.HasPrefix(name, "@owdproject/") && !seen[name] {
2929
+ deps = append(deps, name)
2930
+ seen[name] = true
2931
+ }
2932
+ }
2933
+ for name := range pkg.DevDependencies {
2934
+ if strings.HasPrefix(name, "@owdproject/") && !seen[name] {
2935
+ deps = append(deps, name)
2936
+ seen[name] = true
2937
+ }
2938
+ }
2939
+ return deps, nil
2940
+ }
2941
+
2942
+ func (m *TuiModel) loadPackageDetails(pkg *bridge.CatalogEntry) {
2943
+ m.promptPkgDeps = []string{}
2944
+ m.promptPkgHasPrimevue = false
2945
+ m.promptPkgHasTailwind = false
2946
+
2947
+ if pkg == nil {
2948
+ return
2949
+ }
2950
+
2951
+ short := pkg.ShortName
2952
+ var pathsToCheck []string
2953
+
2954
+ kindDir := ""
2955
+ switch pkg.Kind {
2956
+ case "app":
2957
+ kindDir = "apps"
2958
+ case "module":
2959
+ kindDir = "packages"
2960
+ case "theme":
2961
+ kindDir = "themes"
2962
+ }
2963
+
2964
+ if kindDir != "" {
2965
+ pathsToCheck = append(pathsToCheck, filepath.Join(m.workspaceRoot, kindDir, short, "package.json"))
2966
+ }
2967
+ pathsToCheck = append(pathsToCheck, filepath.Join(m.workspaceRoot, "node_modules", "@owdproject", short, "package.json"))
2968
+
2969
+ var data []byte
2970
+ var err error
2971
+ for _, p := range pathsToCheck {
2972
+ data, err = os.ReadFile(p)
2973
+ if err == nil {
2974
+ break
2975
+ }
2976
+ }
2977
+
2978
+ if err != nil {
2979
+ return
2980
+ }
2981
+
2982
+ var pkgJson struct {
2983
+ Dependencies map[string]string `json:"dependencies"`
2984
+ DevDependencies map[string]string `json:"devDependencies"`
2985
+ }
2986
+
2987
+ if err := json.Unmarshal(data, &pkgJson); err == nil {
2988
+ checkDep := func(name string) {
2989
+ if strings.HasPrefix(name, "@owdproject/") {
2990
+ for _, d := range m.promptPkgDeps {
2991
+ if d == name {
2992
+ return
2993
+ }
2994
+ }
2995
+ m.promptPkgDeps = append(m.promptPkgDeps, name)
2996
+ }
2997
+ if name == "@owdproject/kit-primevue" {
2998
+ m.promptPkgHasPrimevue = true
2999
+ }
3000
+ if name == "@owdproject/kit-tailwind" || name == "tailwindcss" {
3001
+ m.promptPkgHasTailwind = true
3002
+ }
3003
+ }
3004
+ for name := range pkgJson.Dependencies {
3005
+ checkDep(name)
3006
+ }
3007
+ for name := range pkgJson.DevDependencies {
3008
+ checkDep(name)
3009
+ }
3010
+ }
3011
+ }
3012
+
3013
+ // keep rand used (for future sparkline noise simulation)
3014
+ var _ = rand.Float64