summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.opencode/skill/tui-iteration/SKILL.md287
-rw-r--r--AGENTS.md (renamed from CLAUDE.md)0
-rw-r--r--internal/tui/app.go60
-rw-r--r--internal/tui/history_box.go81
-rw-r--r--internal/tui/modal.go6
-rw-r--r--internal/tui/projects_box.go66
-rw-r--r--internal/tui/shared.go92
-rw-r--r--internal/tui/timer_box.go67
8 files changed, 489 insertions, 170 deletions
diff --git a/.opencode/skill/tui-iteration/SKILL.md b/.opencode/skill/tui-iteration/SKILL.md
new file mode 100644
index 0000000..d28f0a7
--- /dev/null
+++ b/.opencode/skill/tui-iteration/SKILL.md
@@ -0,0 +1,287 @@
+---
+name: tui-iteration
+description: Iteratively develop and refine Terminal User Interfaces (TUIs) by using tmux to spawn, observe, and interact with a running TUI process. Use this skill when asked to build, debug, or visually refine a TUI application — any time you need to "see" what the terminal renders and react to it, the way Playwright lets a browser agent iterate on web UI.
+---
+
+This skill lets you act as a TUI design agent: launch a TUI in a controlled tmux pane, capture its rendered output, send keystrokes to interact with it, observe the result, and iterate — all from the CLI. The tmux pane is your "browser tab"; `capture-pane` is your "screenshot".
+
+---
+
+## Core Concept
+
+TUIs render to a virtual terminal buffer. `tmux` holds that buffer in memory at all times, even when the pane is not visible. You can:
+
+1. **Spawn** a tmux session/pane and run the TUI inside it.
+2. **Capture** the current rendered state (text + ANSI color codes) as a snapshot.
+3. **Send** keystrokes or input to drive the TUI.
+4. **Repeat** — capture → evaluate → send → capture — until the UI looks right.
+
+This is the TUI equivalent of Playwright's `page.screenshot()` + `page.click()` loop.
+
+---
+
+## Setup
+
+### Create a dedicated session
+
+```bash
+tmux new-session -d -s tui-dev -x 220 -y 50
+```
+
+- `-d` — detached (runs in background, no need to attach)
+- `-s tui-dev` — session name; use this name in all subsequent commands
+- `-x 220 -y 50` — terminal dimensions (columns × rows); set to match your target environment
+
+### Run the TUI in that session
+
+```bash
+tmux send-keys -t tui-dev 'python my_tui.py' Enter
+# or
+tmux send-keys -t tui-dev 'cargo run' Enter
+# or any other start command
+```
+
+Give the process a moment to render before capturing:
+
+```bash
+sleep 0.5
+```
+
+---
+
+## Capturing State ("Taking a Screenshot")
+
+### Plain text capture
+
+```bash
+tmux capture-pane -t tui-dev -p
+```
+
+Prints the current pane content to stdout as plain text. Good for reading layout, text content, and cursor position.
+
+### Capture with ANSI color codes preserved
+
+```bash
+tmux capture-pane -t tui-dev -p -e
+```
+
+The `-e` flag includes escape sequences for colors and styling. Pipe to a file if you want to save a snapshot:
+
+```bash
+tmux capture-pane -t tui-dev -p -e > snapshot.txt
+```
+
+Render that snapshot back to your terminal to visually inspect it:
+
+```bash
+cat snapshot.txt
+```
+
+### Capture a scrollback window (not just the visible area)
+
+```bash
+tmux capture-pane -t tui-dev -p -S -100
+```
+
+`-S -100` captures 100 lines above the visible area. Useful if the TUI printed startup logs.
+
+### Save to a named tmux buffer (instead of stdout)
+
+```bash
+tmux capture-pane -t tui-dev -b my-snapshot
+tmux show-buffer -b my-snapshot
+```
+
+---
+
+## Sending Input ("Clicking / Typing")
+
+### Type text
+
+```bash
+tmux send-keys -t tui-dev 'hello world' ''
+# Note: omitting Enter means no newline — useful for text fields
+```
+
+### Press Enter
+
+```bash
+tmux send-keys -t tui-dev '' Enter
+```
+
+### Arrow keys and navigation
+
+```bash
+tmux send-keys -t tui-dev '' Up
+tmux send-keys -t tui-dev '' Down
+tmux send-keys -t tui-dev '' Left
+tmux send-keys -t tui-dev '' Right
+```
+
+### Special keys
+
+```bash
+tmux send-keys -t tui-dev '' Tab
+tmux send-keys -t tui-dev '' Escape
+tmux send-keys -t tui-dev '' BSpace # Backspace
+tmux send-keys -t tui-dev '' DC # Delete
+tmux send-keys -t tui-dev '' Home
+tmux send-keys -t tui-dev '' End
+tmux send-keys -t tui-dev '' PPage # Page Up
+tmux send-keys -t tui-dev '' NPage # Page Down
+tmux send-keys -t tui-dev '' F1 # Function keys F1–F12
+```
+
+### Control sequences
+
+```bash
+tmux send-keys -t tui-dev 'q' '' # Just the letter q (quit in many TUIs)
+tmux send-keys -t tui-dev '' C-c # Ctrl+C
+tmux send-keys -t tui-dev '' C-d # Ctrl+D (EOF)
+tmux send-keys -t tui-dev '' C-z # Ctrl+Z (suspend)
+```
+
+### Hex / raw escape sequences (advanced)
+
+```bash
+# Send Escape then [ then A (same as Up arrow in many terminals)
+tmux send-keys -t tui-dev $'\x1b[A' ''
+```
+
+---
+
+## Targeting Specific Panes
+
+If your session has multiple windows or panes:
+
+```bash
+# target syntax: session:window.pane
+tmux send-keys -t tui-dev:0.0 '' Enter # session tui-dev, window 0, pane 0
+tmux capture-pane -t tui-dev:1.0 -p # window 1, pane 0
+```
+
+List all panes to find the right target:
+
+```bash
+tmux list-panes -a
+```
+
+---
+
+## Resize the Terminal (Test Responsive Layouts)
+
+```bash
+tmux resize-window -t tui-dev -x 80 -y 24 # classic 80×24
+tmux resize-window -t tui-dev -x 120 -y 40 # wider
+```
+
+After resizing, most TUI frameworks emit a `SIGWINCH` and redraw automatically. Capture again after ~200ms.
+
+---
+
+## The Iteration Loop
+
+This is the core workflow. Repeat until satisfied:
+
+```
+1. send-keys → trigger an action or type input
+2. sleep 0.2 → give the TUI time to redraw
+3. capture-pane -p -e → observe the new state
+4. evaluate → does it look right? any errors visible?
+5. go to 1
+```
+
+Example shell loop for automated checking:
+
+```bash
+# Press Down 5 times and snapshot each state
+for i in $(seq 1 5); do
+ tmux send-keys -t tui-dev '' Down
+ sleep 0.15
+ echo "--- Step $i ---"
+ tmux capture-pane -t tui-dev -p
+done
+```
+
+---
+
+## Reading the Captured Output
+
+When you receive the text from `capture-pane -p`, look for:
+
+- **Borders and box-drawing characters** (`─`, `│`, `╭`, `╰`, `┌`, `└`, etc.) — indicate panels, modals, popups
+- **Highlighted / reversed rows** — often indicated by `[7m` in ANSI output (reverse video = selected item)
+- **Color shifts** — `[32m` green, `[31m` red, `[33m` yellow — often encode status
+- **Cursor position** — `tmux display-message -t tui-dev -p '#{cursor_x},#{cursor_y}'`
+- **Blank regions** — unexpected whitespace may mean a rendering bug or layout overflow
+
+To strip ANSI codes for pure text analysis:
+
+```bash
+tmux capture-pane -t tui-dev -p -e | sed 's/\x1b\[[0-9;]*m//g'
+```
+
+---
+
+## Useful Diagnostic Commands
+
+```bash
+# What is currently running in the pane?
+tmux display-message -t tui-dev -p '#{pane_current_command}'
+
+# Get pane dimensions
+tmux display-message -t tui-dev -p '#{pane_width}x#{pane_height}'
+
+# Get cursor position
+tmux display-message -t tui-dev -p 'cursor: #{cursor_x},#{cursor_y}'
+
+# Is the pane still alive?
+tmux list-panes -t tui-dev -F '#{pane_id} #{pane_dead} #{pane_current_command}'
+
+# Watch the pane live (attach briefly)
+tmux attach -t tui-dev
+# Detach again with: Ctrl+B then D
+```
+
+---
+
+## Teardown
+
+Kill the TUI process without destroying the session:
+
+```bash
+tmux send-keys -t tui-dev '' C-c
+```
+
+Kill the whole session when done:
+
+```bash
+tmux kill-session -t tui-dev
+```
+
+---
+
+## Workflow Summary
+
+| Goal | Command |
+|------|---------|
+| Create session | `tmux new-session -d -s tui-dev -x 220 -y 50` |
+| Start TUI | `tmux send-keys -t tui-dev 'CMD' Enter` |
+| Snapshot (plain) | `tmux capture-pane -t tui-dev -p` |
+| Snapshot (with color) | `tmux capture-pane -t tui-dev -p -e` |
+| Send keystroke | `tmux send-keys -t tui-dev '' Down` |
+| Type text | `tmux send-keys -t tui-dev 'text' ''` |
+| Resize terminal | `tmux resize-window -t tui-dev -x W -y H` |
+| Cursor position | `tmux display-message -t tui-dev -p '#{cursor_x},#{cursor_y}'` |
+| Kill process | `tmux send-keys -t tui-dev '' C-c` |
+| Kill session | `tmux kill-session -t tui-dev` |
+
+---
+
+## Tips
+
+- **Always `sleep` after `send-keys`** before capturing. TUIs need a render cycle. 100–300ms is usually enough; animations may need longer.
+- **Prefer `-e` captures when debugging visual issues** — color is often load-bearing information in TUIs (errors in red, selection in reverse video, etc.).
+- **Use a wide session** (`-x 220`) by default. Many TUI layout bugs only appear at narrow widths; you can always resize down to test.
+- **Scrollback is truncated** — `capture-pane` only sees the visible buffer plus scrollback history. If the TUI clears the screen on launch, earlier output is gone.
+- **Mouse events** can be sent via `tmux send-keys` using raw escape sequences, but most TUI iteration tasks don't need them — keyboard navigation is sufficient.
diff --git a/CLAUDE.md b/AGENTS.md
index 8d10148..8d10148 100644
--- a/CLAUDE.md
+++ b/AGENTS.md
diff --git a/internal/tui/app.go b/internal/tui/app.go
index 38457ff..9850595 100644
--- a/internal/tui/app.go
+++ b/internal/tui/app.go
@@ -459,43 +459,67 @@ func (m AppModel) View() string {
return "Loading..."
}
+ // Constrain content width for large displays
+ contentWidth := m.width
+ if contentWidth > maxContentWidth {
+ contentWidth = maxContentWidth
+ }
+
topBarHeight := 1
bottomBarHeight := 2
contentHeight := m.height - topBarHeight - bottomBarHeight
- // Timer box top-left
- timerBoxWidth := (m.width / 3)
- timerBoxHeight := (contentHeight / 2)
- if timerBoxWidth < 30 {
- timerBoxWidth = 30
+ // Sidebar: fixed comfortable width, capped to avoid dominating
+ sidebarWidth := 38
+ if sidebarWidth > contentWidth/3 {
+ sidebarWidth = contentWidth / 3
+ }
+ if sidebarWidth < 30 {
+ sidebarWidth = 30
+ }
+
+ // Timer box is compact - just needs enough for the timer display
+ timerBoxHeight := contentHeight * 2 / 5
+ if timerBoxHeight < 8 {
+ timerBoxHeight = 8
+ }
+ if timerBoxHeight > 14 {
+ timerBoxHeight = 14
}
- // Projects box bottom-left
- projectsBoxWidth := timerBoxWidth
+ // Projects box gets the rest of the left column
projectsBoxHeight := contentHeight - timerBoxHeight
// History box right side full height
- historyBoxWidth := m.width - projectsBoxWidth
+ historyBoxWidth := contentWidth - sidebarWidth
historyBoxHeight := contentHeight
- activeDur := m.timerBox.activeTime()
- stats := m.timeStats
- stats.TodayTotal += activeDur
- stats.WeekTotal += activeDur
+ topBar := RenderTopBar(m, contentWidth)
- topBar := RenderTopBar(m)
-
- timerBox := m.timerBox.View(timerBoxWidth, timerBoxHeight, m.selectedBox == TimerBox)
- projectsBox := m.projectsBox.View(projectsBoxWidth, projectsBoxHeight, m.selectedBox == ProjectsBox)
+ timerBox := m.timerBox.View(sidebarWidth, timerBoxHeight, m.selectedBox == TimerBox)
+ projectsBox := m.projectsBox.View(sidebarWidth, projectsBoxHeight, m.selectedBox == ProjectsBox)
historyBox := m.historyBox.View(historyBoxWidth, historyBoxHeight, m.selectedBox == HistoryBox, m.timerBox, m.projectsBox.clients, m.projectsBox.projects)
leftColumn := lipgloss.JoinVertical(lipgloss.Left, timerBox, projectsBox)
mainContent := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, historyBox)
keyBindings := activeBindings(m.selectedBox, m.historyBox.viewLevel, m.modalBox)
- bottomBar := RenderBottomBar(m, keyBindings, m.err)
+ bottomBar := RenderBottomBar(m, keyBindings, m.err, contentWidth)
+
+ fullView := topBar + "\n" + mainContent + "\n" + bottomBar
+
+ // Apply modal overlay (uses contentWidth for centering within the content area)
+ fullView = m.modalBox.RenderCenteredOver(fullView, m, contentWidth)
+
+ // Center the content if terminal is wider than max
+ if m.width > contentWidth {
+ fullView = lipgloss.NewStyle().
+ Width(m.width).
+ Align(lipgloss.Center).
+ Render(fullView)
+ }
- return m.modalBox.RenderCenteredOver(topBar+"\n"+mainContent+"\n"+bottomBar, m)
+ return fullView
}
// dataUpdatedMsg is sent when data is updated from the database
diff --git a/internal/tui/history_box.go b/internal/tui/history_box.go
index 17958c3..9e01f2f 100644
--- a/internal/tui/history_box.go
+++ b/internal/tui/history_box.go
@@ -184,7 +184,7 @@ func (m HistoryBoxModel) View(width, height int, isSelected bool, timer TimerBox
var content string
if len(m.entries) == 0 {
- content = "📝 Recent History\n\n"
+ content = titleStyle.Render("History") + "\n\n"
content += inactiveTimerStyle.Render("No recent entries\n\nStart tracking time to\nsee your history here.")
} else {
switch m.viewLevel {
@@ -224,23 +224,18 @@ func (m HistoryBoxModel) selectionHeight() int {
}
func (m HistoryBoxModel) summarySelectionHeight() int {
- height := 1 // "Recent History" title line
-
- if len(m.summaryItems) > 0 {
- height += 3 // 2 newlines + filter info line
- }
+ height := 1 // title line (History + filter info on same line)
var date *time.Time
for i, item := range m.summaryItems {
if date == nil || !date.Equal(item.Date) {
date = &item.Date
- height += 4 // 2 newlines, the date, 1 more newline
+ height += 2 // blank line + date header
}
- height += 1 // newline before the selectable line
+ height += 1 // the entry line
if i == m.summarySelection {
return height
}
- height += 1 // the selectable line that's not selected
}
return 0
}
@@ -259,25 +254,26 @@ func (m HistoryBoxModel) detailsSelectionHeight() int {
var (
titleStyle = lipgloss.NewStyle().Bold(true)
- dateStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3"))
+ dateStyle = lipgloss.NewStyle().Bold(true).Foreground(colorDate)
summaryItemStyle = lipgloss.NewStyle()
- selectedItemStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230"))
+ selectedItemStyle = lipgloss.NewStyle().Background(colorSelected).Foreground(colorSelectedFg)
+ durationStyle = lipgloss.NewStyle().Foreground(colorDimmed)
entryStyle = lipgloss.NewStyle()
- selectedEntryStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230"))
- activeEntryStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("196"))
- selectedActiveEntryStyle = lipgloss.NewStyle().Background(lipgloss.Color("196")).Foreground(lipgloss.Color("230"))
- descriptionStyle = lipgloss.NewStyle()
- activeDescriptionStyle = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230"))
- filterInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("246"))
+ selectedEntryStyle = lipgloss.NewStyle().Background(colorSelected).Foreground(colorSelectedFg)
+ activeEntryStyle = lipgloss.NewStyle().Bold(true).Foreground(colorTimerText)
+ selectedActiveEntryStyle = lipgloss.NewStyle().Background(colorWarning).Foreground(colorSelectedFg)
+ descriptionStyle = lipgloss.NewStyle().Foreground(colorDimmed)
+ activeDescriptionStyle = lipgloss.NewStyle().Background(colorSelected).Foreground(colorSelectedFg)
+ filterInfoStyle = lipgloss.NewStyle().Foreground(colorDimmed)
)
// renderSummaryView renders the summary view (level 1) with date headers and client/project summaries
func (m HistoryBoxModel) renderSummaryView(timer TimerBoxModel, clients []queries.Client, projects map[int64][]queries.Project) string {
- content := titleStyle.Render("📝 Recent History")
+ content := titleStyle.Render("History")
if len(m.summaryItems) > 0 {
filterInfo := m.formatFilterInfo(clients, projects, timer)
- content += "\n\n" + filterInfoStyle.Render(filterInfo)
+ content += " " + filterInfoStyle.Render(filterInfo)
}
var activeKey HistorySummaryKey
@@ -292,19 +288,15 @@ func (m HistoryBoxModel) renderSummaryView(timer TimerBoxModel, clients []querie
}
if len(m.summaryItems) == 0 {
- return "\n\nNo recent entries found."
+ return content + "\n\n" + filterInfoStyle.Render("No entries found.")
}
var date *time.Time
for i, item := range m.summaryItems {
if date == nil || !date.Equal(item.Date) {
date = &item.Date
- content += fmt.Sprintf("\n\n%s\n", dateStyle.Render(date.Format("Mon 01/02")))
- }
-
- style := summaryItemStyle
- if m.summarySelection == i {
- style = selectedItemStyle
+ content += "\n"
+ content += fmt.Sprintf("\n%s", dateStyle.Render(date.Format("Mon Jan 02")))
}
dur := item.TotalDuration
@@ -312,8 +304,16 @@ func (m HistoryBoxModel) renderSummaryView(timer TimerBoxModel, clients []querie
dur += timer.currentTime.Sub(timer.timerInfo.StartTime)
}
- line := fmt.Sprintf(" %s (%s)", m.formatSummaryTitle(item), FormatDuration(dur))
- content += fmt.Sprintf("\n%s", style.Render(line))
+ title := m.formatSummaryTitle(item)
+ durStr := FormatDuration(dur)
+
+ if m.summarySelection == i {
+ line := fmt.Sprintf(" %s %s", title, durStr)
+ content += "\n" + selectedItemStyle.Render(line)
+ } else {
+ line := fmt.Sprintf(" %s ", title)
+ content += "\n" + summaryItemStyle.Render(line) + durationStyle.Render(durStr)
+ }
}
return content
@@ -338,12 +338,13 @@ func (m HistoryBoxModel) selectedEntries() []queries.TimeEntry {
func (m HistoryBoxModel) renderDetailsView(timer TimerBoxModel) string {
summary := m.summaryItems[m.summarySelection]
clientProject := m.formatSummaryTitle(summary)
- date := summary.Date.Format("Mon 01/02")
- content := titleStyle.Render(fmt.Sprintf("📝 %s on %s", clientProject, date)) + "\n\n"
+ date := summary.Date.Format("Mon Jan 02")
+ content := titleStyle.Render(clientProject) +
+ filterInfoStyle.Render(" "+date) + "\n\n"
entries := m.selectedEntries()
if len(entries) == 0 {
- return "No entries found for this selection."
+ return content + filterInfoStyle.Render("No entries found.")
}
for i, entry := range entries {
@@ -363,7 +364,7 @@ func (m HistoryBoxModel) renderDetailsView(timer TimerBoxModel) string {
timeRange = fmt.Sprintf("%s - now", startTime)
}
- entryLine := fmt.Sprintf("%s (%s)", timeRange, FormatDuration(duration))
+ durStr := FormatDuration(duration)
var style lipgloss.Style
if m.detailSelection == i {
@@ -380,14 +381,20 @@ func (m HistoryBoxModel) renderDetailsView(timer TimerBoxModel) string {
}
}
- content += style.Render(entryLine)
-
- descStyle := descriptionStyle
if m.detailSelection == i {
- descStyle = activeDescriptionStyle
+ entryLine := fmt.Sprintf(" %s %s", timeRange, durStr)
+ content += style.Render(entryLine)
+ } else {
+ content += style.Render(fmt.Sprintf(" %s ", timeRange))
+ content += durationStyle.Render(durStr)
}
+
if entry.Description.Valid {
- content += descStyle.Render(fmt.Sprintf(" \"%s\"", entry.Description.String))
+ descStyle := descriptionStyle
+ if m.detailSelection == i {
+ descStyle = activeDescriptionStyle
+ }
+ content += descStyle.Render(" " + entry.Description.String)
}
content += "\n"
diff --git a/internal/tui/modal.go b/internal/tui/modal.go
index b614d04..0e6c517 100644
--- a/internal/tui/modal.go
+++ b/internal/tui/modal.go
@@ -78,7 +78,7 @@ func (m *ModalBoxModel) HandleKeyPress(msg tea.KeyMsg) tea.Cmd {
return cmd
}
-func (m ModalBoxModel) RenderCenteredOver(mainContent string, app AppModel) string {
+func (m ModalBoxModel) RenderCenteredOver(mainContent string, app AppModel, contentWidth int) string {
if !m.Active {
return mainContent
}
@@ -86,12 +86,12 @@ func (m ModalBoxModel) RenderCenteredOver(mainContent string, app AppModel) stri
modalContent := m.Render()
base := lipgloss.NewLayer(mainContent)
- overlayStyle := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("62")).Padding(2, 4)
+ overlayStyle := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colorAccent).Padding(2, 4)
overlay := lipgloss.NewLayer(overlayStyle.Render(modalContent))
overlayWidth := overlay.GetWidth()
overlayHeight := overlay.GetHeight()
- overlayLeft := (app.width - overlayWidth) / 2
+ overlayLeft := (contentWidth - overlayWidth) / 2
overlayTop := (app.height - overlayHeight) / 2
canvas := lipgloss.NewCanvas(
diff --git a/internal/tui/projects_box.go b/internal/tui/projects_box.go
index cd50d5e..d909e20 100644
--- a/internal/tui/projects_box.go
+++ b/internal/tui/projects_box.go
@@ -74,7 +74,7 @@ func (m ClientsProjectsModel) View(width, height int, isSelected bool) string {
style = selectedBoxStyle
}
- title := titleStyle.Render("👥 Clients & Projects")
+ title := titleStyle.Render("Clients & Projects")
return style.Width(width).Height(height).Render(
fmt.Sprintf("%s\n\n%s", title, content),
@@ -86,57 +86,51 @@ func (m ClientsProjectsModel) renderClientsAndProjects() string {
var content string
visibleClients := m.visibleClients()
+ rateStyle := lipgloss.NewStyle().Foreground(colorDimmed)
+ treeStyle := lipgloss.NewStyle().Foreground(colorSubtle)
+
for i, client := range visibleClients {
if i > 0 {
content += "\n"
}
- // Build client name and rate
- clientText := client.Name
- if client.BillableRate.Valid {
- rateInDollars := float64(client.BillableRate.Int64) / 100.0
- clientText += fmt.Sprintf(" ($%.2f/hr)", rateInDollars)
- }
-
// Style for client text
clientStyle := lipgloss.NewStyle().Bold(true)
if m.selectedClient == i && m.selectedProject == nil {
- clientStyle = clientStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230"))
+ clientStyle = clientStyle.Background(colorSelected).Foreground(colorSelectedFg)
} else if client.Archived != 0 {
- // Gray out archived clients
- clientStyle = clientStyle.Foreground(lipgloss.Color("246"))
+ clientStyle = clientStyle.Foreground(colorDimmed)
}
- content += "• " + clientStyle.Render(clientText) + "\n"
+ clientText := client.Name
+ content += clientStyle.Render(clientText)
+ if client.BillableRate.Valid && !(m.selectedClient == i && m.selectedProject == nil) {
+ rateInDollars := float64(client.BillableRate.Int64) / 100.0
+ content += rateStyle.Render(fmt.Sprintf(" $%g", rateInDollars))
+ }
+ content += "\n"
visibleProjects := m.visibleProjects(client.ID)
- if len(visibleProjects) == 0 {
- content += " └── (no projects)\n"
- } else {
- for j, project := range visibleProjects {
- prefix := "├──"
- if j == len(visibleProjects)-1 {
- prefix = "└──"
- }
-
- // Build project name and rate
- projectText := project.Name
- if project.BillableRate.Valid {
- rateInDollars := float64(project.BillableRate.Int64) / 100.0
- projectText += fmt.Sprintf(" ($%.2f/hr)", rateInDollars)
- }
+ for j, project := range visibleProjects {
+ prefix := "├ "
+ if j == len(visibleProjects)-1 {
+ prefix = "└ "
+ }
- // Style for project text
- projectStyle := lipgloss.NewStyle()
- if m.selectedClient == i && m.selectedProject != nil && *m.selectedProject == j {
- projectStyle = projectStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230"))
- } else if project.Archived != 0 {
- // Gray out archived projects
- projectStyle = projectStyle.Foreground(lipgloss.Color("246"))
- }
+ // Style for project text
+ projectStyle := lipgloss.NewStyle()
+ if m.selectedClient == i && m.selectedProject != nil && *m.selectedProject == j {
+ projectStyle = projectStyle.Background(colorSelected).Foreground(colorSelectedFg)
+ } else if project.Archived != 0 {
+ projectStyle = projectStyle.Foreground(colorDimmed)
+ }
- content += fmt.Sprintf(" %s ", prefix) + projectStyle.Render(projectText) + "\n"
+ content += treeStyle.Render(" "+prefix) + projectStyle.Render(project.Name)
+ if project.BillableRate.Valid && !(m.selectedClient == i && m.selectedProject != nil && *m.selectedProject == j) {
+ rateInDollars := float64(project.BillableRate.Int64) / 100.0
+ content += rateStyle.Render(fmt.Sprintf(" $%g", rateInDollars))
}
+ content += "\n"
}
}
diff --git a/internal/tui/shared.go b/internal/tui/shared.go
index 7271293..9025fb8 100644
--- a/internal/tui/shared.go
+++ b/internal/tui/shared.go
@@ -13,34 +13,57 @@ import (
"github.com/charmbracelet/lipgloss/v2"
)
+// Maximum content width for large displays - prevents over-stretching
+const maxContentWidth = 180
+
var (
+ // Color palette
+ colorAccent = lipgloss.Color("4") // Blue accent
+ colorAccentBright = lipgloss.Color("12") // Bright blue
+ colorTimerActive = lipgloss.Color("2") // Green for active timer
+ colorTimerText = lipgloss.Color("10") // Bright green
+ colorDimmed = lipgloss.Color("242") // Dimmed text
+ colorSubtle = lipgloss.Color("238") // Very subtle borders/separators
+ colorFg = lipgloss.Color("253") // Main foreground
+ colorBg = lipgloss.Color("235") // Slightly lighter than terminal default
+ colorBarBg = lipgloss.Color("236") // Bar background
+ colorSelected = lipgloss.Color("4") // Selection background
+ colorSelectedFg = lipgloss.Color("15") // White text on selection
+ colorDate = lipgloss.Color("6") // Cyan for dates
+ colorWarning = lipgloss.Color("1") // Red for warnings/errors
+
// Styles for the TUI
- topBarInactiveStyle = lipgloss.NewStyle().
- Background(lipgloss.Color("21")).
- Foreground(lipgloss.Color("230")).
- Padding(0, 1)
+ topBarStyle = lipgloss.NewStyle().
+ Background(colorBarBg).
+ Foreground(colorFg).
+ Padding(0, 1)
bottomBarStyle = lipgloss.NewStyle().
- Background(lipgloss.Color("238")).
- Foreground(lipgloss.Color("252"))
+ Background(colorBarBg).
+ Foreground(colorDimmed)
// Box styles
selectedBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("62")).
+ BorderForeground(colorAccent).
Padding(1, 2)
unselectedBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("238")).
+ BorderForeground(colorSubtle).
Padding(1, 2)
activeTimerStyle = lipgloss.NewStyle().
- Foreground(lipgloss.Color("196")).
+ Foreground(colorTimerText).
Bold(true)
+ activeBoxStyle = lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(colorTimerActive).
+ Padding(1, 2)
+
inactiveTimerStyle = lipgloss.NewStyle().
- Foreground(lipgloss.Color("246"))
+ Foreground(colorDimmed)
)
// FormatDuration formats a duration in a human-readable way
@@ -66,7 +89,7 @@ func getContractorInfo(ctx context.Context, q *queries.Queries) (ContractorInfo,
}
return ContractorInfo{
- name: c.Name,
+ name: c.Name,
label: c.Label,
email: c.Email,
}, nil
@@ -133,8 +156,8 @@ func getMostRecentTimerInfo(ctx context.Context, q *queries.Queries) (TimerInfo,
}
// RenderTopBar renders the top bar with view name and time stats
-func RenderTopBar(m AppModel) string {
- left := fmt.Sprintf("👊 Punchcard ♦️ - %s", m.selectedBox.String())
+func RenderTopBar(m AppModel, contentWidth int) string {
+ leftText := fmt.Sprintf("Punchcard / %s", m.selectedBox.String())
today := m.timeStats.TodayTotal
week := m.timeStats.WeekTotal
@@ -145,35 +168,35 @@ func RenderTopBar(m AppModel) string {
week += activeTime
}
- right := fmt.Sprintf("Today: %s | Week: %s", FormatDuration(today), FormatDuration(week))
+ rightText := fmt.Sprintf("today %s week %s", FormatDuration(today), FormatDuration(week))
- // Use lipgloss to create left and right aligned content
leftStyle := lipgloss.NewStyle().Align(lipgloss.Left)
rightStyle := lipgloss.NewStyle().Align(lipgloss.Right)
- // Calculate available width for content (minus padding)
- contentWidth := m.width - 2 // Account for horizontal padding
+ // Account for the 2 chars of padding in topBarStyle
+ innerWidth := contentWidth - 2
- // Create a layout with left and right content
content := lipgloss.JoinHorizontal(
lipgloss.Top,
- leftStyle.Width(contentWidth/2).Render(left),
- rightStyle.Width(contentWidth/2).Render(right),
+ leftStyle.Width(innerWidth/2).Render(leftText),
+ rightStyle.Width(innerWidth-innerWidth/2).Render(rightText),
)
- return topBarInactiveStyle.Width(m.width).Render(content)
+ return topBarStyle.Width(contentWidth).Render(content)
}
// RenderBottomBar renders the bottom bar with key bindings
-func RenderBottomBar(m AppModel, bindings []KeyBinding, err error) string {
+func RenderBottomBar(m AppModel, bindings []KeyBinding, err error, contentWidth int) string {
var content string
- // Style for keys (inherits background from bottomBarStyle)
- keyStyle := bottomBarStyle.Bold(true)
- // Style for descriptions (inherits background from bottomBarStyle)
- descStyle := bottomBarStyle
- // Style for separators (inherits background from bottomBarStyle)
- sepStyle := bottomBarStyle
+ keyStyle := lipgloss.NewStyle().
+ Background(lipgloss.Color("240")).
+ Foreground(lipgloss.Color("15")).
+ Padding(0, 1)
+ descStyle := lipgloss.NewStyle().
+ Background(colorBarBg).
+ Foreground(colorDimmed)
+ sepStyle := lipgloss.NewStyle().Background(colorBarBg)
for i, binding := range bindings {
if binding.Hide {
@@ -184,20 +207,19 @@ func RenderBottomBar(m AppModel, bindings []KeyBinding, err error) string {
continue
}
if i > 0 {
- content += sepStyle.Render(" ")
+ content += sepStyle.Render(" ")
}
- content += keyStyle.Render(fmt.Sprintf("[%s]", binding.Key))
- content += descStyle.Render(fmt.Sprintf(" %s", desc))
+ content += keyStyle.Render(binding.Key)
+ content += descStyle.Render(" " + desc)
}
if err != nil {
- // Style for errors (inherits background, adds red foreground)
- errorStyle := bottomBarStyle.Bold(true).Foreground(lipgloss.Color("196"))
+ errStyle := lipgloss.NewStyle().Background(colorBarBg).Bold(true).Foreground(colorWarning)
content += sepStyle.Render(" ")
- content += errorStyle.Render(err.Error())
+ content += errStyle.Render(err.Error())
}
- return bottomBarStyle.Width(m.width).Align(lipgloss.Left).Render(content)
+ return bottomBarStyle.Width(contentWidth).Align(lipgloss.Left).Render(content)
}
// GetAppData fetches all data needed for the TUI
diff --git a/internal/tui/timer_box.go b/internal/tui/timer_box.go
index 891004a..2b0c34a 100644
--- a/internal/tui/timer_box.go
+++ b/internal/tui/timer_box.go
@@ -1,10 +1,11 @@
package tui
import (
- "fmt"
"time"
"git.tjp.lol/punchcard/internal/queries"
+
+ "github.com/charmbracelet/lipgloss/v2"
)
// TimerInfo holds information about the current or most recent timer state
@@ -66,9 +67,13 @@ func (m TimerBoxModel) View(width, height int, isSelected bool) string {
}
// Apply box styling
- style := unselectedBoxStyle
+ var style lipgloss.Style
if isSelected {
style = selectedBoxStyle
+ } else if m.timerInfo.IsActive {
+ style = activeBoxStyle
+ } else {
+ style = unselectedBoxStyle
}
return style.Width(width).Height(height).Render(content)
@@ -76,67 +81,47 @@ func (m TimerBoxModel) View(width, height int, isSelected bool) string {
// renderActiveTimer renders the active timer display
func (m TimerBoxModel) renderActiveTimer() string {
- content := titleStyle.Render("⏰ Active Timer") + "\n\n"
+ statusStyle := lipgloss.NewStyle().Foreground(colorTimerActive).Bold(true)
+ content := statusStyle.Render("TRACKING") + "\n\n"
- // Timer duration
- timerLine := fmt.Sprintf("Duration: %s", FormatDuration(m.currentTime.Sub(m.timerInfo.StartTime)))
- content += activeTimerStyle.Render(timerLine) + "\n\n"
+ // Timer duration - big and bold
+ dur := FormatDuration(m.currentTime.Sub(m.timerInfo.StartTime))
+ content += activeTimerStyle.Render(dur) + "\n\n"
// Project/Client info
+ dimLabel := lipgloss.NewStyle().Foreground(colorDimmed)
if m.timerInfo.ProjectName != "" {
- projectLine := fmt.Sprintf("Project: %s / %s", m.timerInfo.ClientName, m.timerInfo.ProjectName)
- content += projectLine + "\n"
+ content += dimLabel.Render(m.timerInfo.ClientName+" /") + "\n"
+ content += m.timerInfo.ProjectName + "\n"
} else {
- clientLine := fmt.Sprintf("Client: %s", m.timerInfo.ClientName)
- content += clientLine + "\n"
+ content += m.timerInfo.ClientName + "\n"
}
- // Start time (convert from UTC to local)
+ // Start time
localStartTime := m.timerInfo.StartTime.Local()
- startLine := fmt.Sprintf("Started: %s", localStartTime.Format("3:04 PM"))
- content += startLine + "\n"
-
- // Description if available
- if m.timerInfo.Description != nil {
- content += "\n"
- descLine := fmt.Sprintf("Description: %s", *m.timerInfo.Description)
- content += descLine + "\n"
- }
-
- // Billable rate if available
- if m.timerInfo.BillableRate != nil {
- rateLine := fmt.Sprintf("Rate: $%.2f/hr", *m.timerInfo.BillableRate)
- content += rateLine + "\n"
- }
+ content += dimLabel.Render("since " + localStartTime.Format("3:04 PM"))
return content
}
// renderInactiveTimer renders the inactive timer display
func (m TimerBoxModel) renderInactiveTimer() string {
- content := titleStyle.Render("⌛ Last Timer (Inactive)") + "\n\n"
+ statusStyle := lipgloss.NewStyle().Foreground(colorDimmed)
+ content := statusStyle.Render("IDLE") + "\n\n"
if m.timerInfo.EntryID == 0 {
- content += inactiveTimerStyle.Render("No time entries yet.\nSelect a client or project and\npunch in to start tracking time.")
+ content += inactiveTimerStyle.Render("No entries yet.\nPunch in to start.")
return content
}
- timerLine := fmt.Sprintf("Duration: %s", FormatDuration(m.timerInfo.Duration))
- content += inactiveTimerStyle.Render(timerLine) + "\n\n"
+ content += inactiveTimerStyle.Render(FormatDuration(m.timerInfo.Duration)) + "\n\n"
+ dimLabel := lipgloss.NewStyle().Foreground(colorDimmed)
if m.timerInfo.ProjectName != "" {
- content += inactiveTimerStyle.Render(fmt.Sprintf("Project: %s / %s", m.timerInfo.ClientName, m.timerInfo.ProjectName)) + "\n"
+ content += dimLabel.Render(m.timerInfo.ClientName+" /") + "\n"
+ content += inactiveTimerStyle.Render(m.timerInfo.ProjectName)
} else {
- content += inactiveTimerStyle.Render(fmt.Sprintf("Client: %s", m.timerInfo.ClientName)) + "\n"
- }
-
- content += inactiveTimerStyle.Render(fmt.Sprintf("Started: %s", m.timerInfo.StartTime.Local().Format("2006/01/02 3:04 PM"))) + "\n"
-
- if m.timerInfo.Description != nil {
- content += "\n" + inactiveTimerStyle.Render(fmt.Sprintf("Description: %s", *m.timerInfo.Description)) + "\n"
- }
- if m.timerInfo.BillableRate != nil {
- content += inactiveTimerStyle.Render(fmt.Sprintf("Rate: $%.2f/hr", *m.timerInfo.BillableRate)) + "\n"
+ content += inactiveTimerStyle.Render(m.timerInfo.ClientName)
}
return content