2026-03-01-tui-improvements-plan.md (12379B)
1 # TUI Improvements Implementation Plan 2 3 > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 5 **Goal:** Fix the dashboard event list to show grouped top-level results with timestamps, fill the terminal, scroll, and wire up the `r` resync key. 6 7 **Architecture:** Replace per-file "syncing"/"synced" events with a status message for the header and grouped directory-level events. Add scroll offset to the dashboard. Add a resync channel so the TUI can trigger a full sync. Accumulate stats in the handler. 8 9 **Tech Stack:** Go, Bubbletea, Lipgloss 10 11 --- 12 13 ### Task 1: Add new message types to app.go 14 15 **Files:** 16 - Modify: `internal/tui/app.go` 17 18 **Step 1: Add SyncStatusMsg and ResyncRequestMsg types and resync channel** 19 20 In `internal/tui/app.go`, add after the `view` constants (line 16): 21 22 ```go 23 // SyncStatusMsg updates the header status without adding an event. 24 type SyncStatusMsg string 25 26 // ResyncRequestMsg signals that the user pressed 'r' for a full resync. 27 type ResyncRequestMsg struct{} 28 ``` 29 30 Add `resyncCh` field to `AppModel` struct (after `logEntries`): 31 32 ```go 33 resyncCh chan struct{} 34 ``` 35 36 Initialize it in `NewApp`: 37 38 ```go 39 resyncCh: make(chan struct{}, 1), 40 ``` 41 42 Add accessor: 43 44 ```go 45 // ResyncChan returns a channel that receives when the user requests a full resync. 46 func (m *AppModel) ResyncChan() <-chan struct{} { 47 return m.resyncCh 48 } 49 ``` 50 51 **Step 2: Handle new messages in AppModel.Update** 52 53 In the `Update` method's switch, add cases before the `SyncEventMsg` case: 54 55 ```go 56 case SyncStatusMsg: 57 m.dashboard.status = string(msg) 58 return m, nil 59 60 case ResyncRequestMsg: 61 select { 62 case m.resyncCh <- struct{}{}: 63 default: 64 } 65 return m, nil 66 ``` 67 68 **Step 3: Build and verify compilation** 69 70 Run: `go build ./...` 71 Expected: success 72 73 **Step 4: Commit** 74 75 ```bash 76 git add internal/tui/app.go 77 git commit -m "feat(tui): add SyncStatusMsg, ResyncRequestMsg, resync channel" 78 ``` 79 80 --- 81 82 ### Task 2: Update dashboard — timestamps, scrolling, fill terminal 83 84 **Files:** 85 - Modify: `internal/tui/dashboard.go` 86 87 **Step 1: Add scroll offset field** 88 89 Add `offset int` to `DashboardModel` struct (after `filtering`). 90 91 **Step 2: Add j/k/up/down scroll keys and r resync key to updateNormal** 92 93 Replace `updateNormal`: 94 95 ```go 96 func (m DashboardModel) updateNormal(msg tea.KeyMsg) (DashboardModel, tea.Cmd) { 97 switch msg.String() { 98 case "q", "ctrl+c": 99 return m, tea.Quit 100 case "p": 101 if m.status == "paused" { 102 m.status = "watching" 103 } else { 104 m.status = "paused" 105 } 106 case "r": 107 return m, func() tea.Msg { return ResyncRequestMsg{} } 108 case "j", "down": 109 filtered := m.filteredEvents() 110 maxOffset := max(0, len(filtered)-m.eventViewHeight()) 111 if m.offset < maxOffset { 112 m.offset++ 113 } 114 case "k", "up": 115 if m.offset > 0 { 116 m.offset-- 117 } 118 case "/": 119 m.filtering = true 120 m.filter = "" 121 m.offset = 0 122 } 123 return m, nil 124 } 125 ``` 126 127 **Step 3: Add eventViewHeight helper** 128 129 ```go 130 // eventViewHeight returns the number of event rows that fit in the terminal. 131 // Layout: header (3 lines) + "Recent" header (1) + stats section (3) + help (1) = 8 fixed. 132 func (m DashboardModel) eventViewHeight() int { 133 return max(1, m.height-8) 134 } 135 ``` 136 137 **Step 4: Rewrite View to fill terminal with timestamps** 138 139 Replace the `View` method: 140 141 ```go 142 func (m DashboardModel) View() string { 143 var b strings.Builder 144 145 // --- Header (3 lines) --- 146 header := titleStyle.Render(" esync ") + dimStyle.Render(strings.Repeat("─", max(0, m.width-8))) 147 b.WriteString(header + "\n") 148 b.WriteString(fmt.Sprintf(" %s → %s\n", m.local, m.remote)) 149 150 statusIcon, statusText := m.statusDisplay() 151 agoText := "" 152 if !m.lastSync.IsZero() { 153 ago := time.Since(m.lastSync).Truncate(time.Second) 154 agoText = fmt.Sprintf(" (synced %s ago)", ago) 155 } 156 b.WriteString(fmt.Sprintf(" %s %s%s\n", statusIcon, statusText, dimStyle.Render(agoText))) 157 158 // --- Recent events --- 159 b.WriteString(" " + titleStyle.Render("Recent") + " " + dimStyle.Render(strings.Repeat("─", max(0, m.width-11))) + "\n") 160 161 filtered := m.filteredEvents() 162 vh := m.eventViewHeight() 163 start := m.offset 164 end := min(start+vh, len(filtered)) 165 166 for i := start; i < end; i++ { 167 b.WriteString(" " + m.renderEvent(filtered[i]) + "\n") 168 } 169 // Pad empty rows 170 for i := end - start; i < vh; i++ { 171 b.WriteString("\n") 172 } 173 174 // --- Stats (2 lines) --- 175 b.WriteString(" " + titleStyle.Render("Stats") + " " + dimStyle.Render(strings.Repeat("─", max(0, m.width-10))) + "\n") 176 stats := fmt.Sprintf(" %d synced │ %s total │ %d errors", 177 m.totalSynced, m.totalBytes, m.totalErrors) 178 b.WriteString(stats + "\n") 179 180 // --- Help (1 line) --- 181 if m.filtering { 182 b.WriteString(helpStyle.Render(fmt.Sprintf(" filter: %s█ (enter apply esc clear)", m.filter))) 183 } else { 184 help := " q quit p pause r resync ↑↓ scroll l logs / filter" 185 if m.filter != "" { 186 help += fmt.Sprintf(" [filter: %s]", m.filter) 187 } 188 b.WriteString(helpStyle.Render(help)) 189 } 190 b.WriteString("\n") 191 192 return b.String() 193 } 194 ``` 195 196 **Step 5: Update renderEvent to include timestamp** 197 198 Replace `renderEvent`: 199 200 ```go 201 func (m DashboardModel) renderEvent(evt SyncEvent) string { 202 ts := dimStyle.Render(evt.Time.Format("15:04:05")) 203 switch evt.Status { 204 case "synced": 205 name := padRight(evt.File, 30) 206 detail := "" 207 if evt.Size != "" { 208 detail = dimStyle.Render(fmt.Sprintf("%8s %s", evt.Size, evt.Duration.Truncate(100*time.Millisecond))) 209 } 210 return ts + " " + statusSynced.Render("✓") + " " + name + detail 211 case "error": 212 name := padRight(evt.File, 30) 213 return ts + " " + statusError.Render("✗") + " " + name + statusError.Render("error") 214 default: 215 return ts + " " + evt.File 216 } 217 } 218 ``` 219 220 **Step 6: Remove "syncing" case from renderEvent** 221 222 The "syncing" case is no longer needed — it was removed in step 5 above. 223 224 **Step 7: Build and verify** 225 226 Run: `go build ./...` 227 Expected: success 228 229 **Step 8: Commit** 230 231 ```bash 232 git add internal/tui/dashboard.go 233 git commit -m "feat(tui): timestamps, scrolling, fill terminal, resync key" 234 ``` 235 236 --- 237 238 ### Task 3: Top-level grouping and stats accumulation in handler 239 240 **Files:** 241 - Modify: `cmd/sync.go` 242 243 **Step 1: Add groupFiles helper** 244 245 Add after `formatSize` at the bottom of `cmd/sync.go`: 246 247 ```go 248 // groupedEvent represents a top-level directory or root file for the TUI. 249 type groupedEvent struct { 250 name string // "cmd/" or "main.go" 251 count int // number of files (1 for root files) 252 bytes int64 // total bytes 253 } 254 255 // groupFilesByTopLevel collapses file entries into top-level directories 256 // and root files. "cmd/sync.go" + "cmd/init.go" → one entry "cmd/" with count=2. 257 func groupFilesByTopLevel(files []syncer.FileEntry) []groupedEvent { 258 dirMap := make(map[string]*groupedEvent) 259 var rootFiles []groupedEvent 260 var dirOrder []string 261 262 for _, f := range files { 263 parts := strings.SplitN(f.Name, "/", 2) 264 if len(parts) == 1 { 265 // Root-level file 266 rootFiles = append(rootFiles, groupedEvent{ 267 name: f.Name, 268 count: 1, 269 bytes: f.Bytes, 270 }) 271 } else { 272 dir := parts[0] + "/" 273 if g, ok := dirMap[dir]; ok { 274 g.count++ 275 g.bytes += f.Bytes 276 } else { 277 dirMap[dir] = &groupedEvent{name: dir, count: 1, bytes: f.Bytes} 278 dirOrder = append(dirOrder, dir) 279 } 280 } 281 } 282 283 var out []groupedEvent 284 for _, dir := range dirOrder { 285 out = append(out, *dirMap[dir]) 286 } 287 out = append(out, rootFiles...) 288 return out 289 } 290 ``` 291 292 **Step 2: Rewrite TUI handler to use grouping, stats, and status messages** 293 294 Replace the entire `handler` closure inside `runTUI`: 295 296 ```go 297 var totalSynced int 298 var totalBytes int64 299 var totalErrors int 300 301 handler := func() { 302 // Update header status 303 syncCh <- tui.SyncEvent{Status: "status:syncing"} 304 305 result, err := s.Run() 306 now := time.Now() 307 308 if err != nil { 309 totalErrors++ 310 syncCh <- tui.SyncEvent{ 311 File: "sync error", 312 Status: "error", 313 Time: now, 314 } 315 // Reset header 316 syncCh <- tui.SyncEvent{Status: "status:watching"} 317 return 318 } 319 320 // Group files by top-level directory 321 groups := groupFilesByTopLevel(result.Files) 322 for _, g := range groups { 323 file := g.name 324 size := formatSize(g.bytes) 325 if g.count > 1 { 326 file = g.name 327 size = fmt.Sprintf("%d files %s", g.count, formatSize(g.bytes)) 328 } 329 syncCh <- tui.SyncEvent{ 330 File: file, 331 Size: size, 332 Duration: result.Duration, 333 Status: "synced", 334 Time: now, 335 } 336 } 337 338 // Fallback: rsync ran but no individual files parsed 339 if len(groups) == 0 && result.FilesCount > 0 { 340 syncCh <- tui.SyncEvent{ 341 File: fmt.Sprintf("%d files", result.FilesCount), 342 Size: formatSize(result.BytesTotal), 343 Duration: result.Duration, 344 Status: "synced", 345 Time: now, 346 } 347 } 348 349 // Accumulate stats 350 totalSynced += result.FilesCount 351 totalBytes += result.BytesTotal 352 353 // Reset header 354 syncCh <- tui.SyncEvent{Status: "status:watching"} 355 } 356 ``` 357 358 **Step 3: Handle status messages in dashboard Update** 359 360 In `internal/tui/dashboard.go`, update the `SyncEventMsg` case in `Update`: 361 362 ```go 363 case SyncEventMsg: 364 evt := SyncEvent(msg) 365 366 // Status-only messages update the header, not the event list 367 if strings.HasPrefix(evt.Status, "status:") { 368 m.status = strings.TrimPrefix(evt.Status, "status:") 369 return m, nil 370 } 371 372 // Prepend event; cap at 500. 373 m.events = append([]SyncEvent{evt}, m.events...) 374 if len(m.events) > 500 { 375 m.events = m.events[:500] 376 } 377 if evt.Status == "synced" { 378 m.lastSync = evt.Time 379 } 380 return m, nil 381 ``` 382 383 Add `"strings"` to the imports in `dashboard.go` if not already present (it is). 384 385 **Step 4: Send stats after each sync** 386 387 Still in the TUI handler in `cmd/sync.go`, after the status reset, send stats. But we're using the same `syncCh` channel which sends `SyncEvent`. We need a different approach. 388 389 Simpler: update the dashboard's stats directly from the event stream. In `dashboard.go`, update the `SyncEventMsg` handler to accumulate stats: 390 391 ```go 392 if evt.Status == "synced" { 393 m.lastSync = evt.Time 394 m.totalSynced++ 395 // Parse size back (or just count events) 396 } 397 ``` 398 399 Actually, the simplest approach: count synced events and track the `lastSync` time. Remove the `SyncStatsMsg` type and the `totalBytes` / `totalErrors` fields. Replace the stats bar with just event count + last sync time. The exact byte total isn't meaningful in grouped view anyway. 400 401 Replace stats rendering in `View`: 402 403 ```go 404 // --- Stats (2 lines) --- 405 b.WriteString(" " + titleStyle.Render("Stats") + " " + dimStyle.Render(strings.Repeat("─", max(0, m.width-10))) + "\n") 406 stats := fmt.Sprintf(" %d events │ %d errors", m.totalSynced, m.totalErrors) 407 b.WriteString(stats + "\n") 408 ``` 409 410 In the `SyncEventMsg` handler, increment counters: 411 412 ```go 413 if evt.Status == "synced" { 414 m.lastSync = evt.Time 415 m.totalSynced++ 416 } else if evt.Status == "error" { 417 m.totalErrors++ 418 } 419 ``` 420 421 Remove `totalBytes string` from `DashboardModel` and `SyncStatsMsg` type from `dashboard.go`. Remove the `SyncStatsMsg` case from `Update`. 422 423 **Step 5: Wire up resync channel in cmd/sync.go** 424 425 In `runTUI`, after starting the watcher and before creating the tea.Program, add a goroutine: 426 427 ```go 428 resyncCh := app.ResyncChan() 429 go func() { 430 for range resyncCh { 431 handler() 432 } 433 }() 434 ``` 435 436 **Step 6: Build and verify** 437 438 Run: `go build ./...` 439 Expected: success 440 441 **Step 7: Run tests** 442 443 Run: `go test ./...` 444 Expected: all pass 445 446 **Step 8: Commit** 447 448 ```bash 449 git add cmd/sync.go internal/tui/dashboard.go 450 git commit -m "feat(tui): top-level grouping, stats accumulation, resync wiring" 451 ``` 452 453 --- 454 455 ### Task 4: End-to-end verification 456 457 **Step 1: Build binary** 458 459 ```bash 460 GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o esync-darwin-arm64 . 461 ``` 462 463 **Step 2: Test with local docs sync** 464 465 ```bash 466 rm -rf /tmp/esync-docs && mkdir -p /tmp/esync-docs 467 ./esync-darwin-arm64 sync --daemon -v 468 # In another terminal: touch docs/plans/2026-03-01-go-rewrite-design.md 469 # Verify: "Synced 2 files" appears 470 ``` 471 472 **Step 3: Test TUI** 473 474 ```bash 475 ./esync-darwin-arm64 sync 476 # Verify: 477 # - Header shows "● Watching", switches to "⟳ Syncing" during rsync 478 # - Events show timestamps: "15:04:05 ✓ plans/ ..." 479 # - j/k scrolls the event list 480 # - r triggers a full resync 481 # - Event list fills terminal height 482 # - No "⟳ . syncing..." rows in the event list 483 ``` 484 485 **Step 4: Run full test suite** 486 487 Run: `go test ./...` 488 Expected: all pass 489 490 **Step 5: Commit design doc** 491 492 ```bash 493 git add docs/plans/ 494 git commit -m "docs: add TUI improvements design and plan" 495 ```