From aeddbe55a19f32d2b5a72e7687f3666895d5c123 Mon Sep 17 00:00:00 2001 From: "Donovan C. Young" Date: Wed, 29 Apr 2026 16:40:20 -0400 Subject: [PATCH 1/5] fix(tui): stretch prototype panels to available space --- CHANGELOG.md | 2 +- internal/tui/app.go | 46 ++++++++++++++++++++++++---------------- internal/tui/app_test.go | 30 ++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e749e4..b3b8983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Prototype TUI visual pass**: The TUI now expands to the current terminal size, uses more readable muted colors on dark terminals, and assigns each retro theme its own dashboard layout rather than only swapping colors. +- **Prototype TUI visual pass**: The TUI now expands to the current terminal size, uses more readable muted colors on dark terminals, assigns each retro theme its own dashboard layout rather than only swapping colors, and stretches screen panels/sections to use the available content area instead of rendering as fixed-size islands. ## [1.3.10] - 2026-04-26 diff --git a/internal/tui/app.go b/internal/tui/app.go index 54fb438..705d680 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -245,8 +245,11 @@ func (m Model) dashboardView() string { func (m Model) partyDashboardView() string { width := m.availableWidth() + height := m.availableContentHeight() gap := 1 panelWidth := max((width-gap)/2, 24) + topHeight := max((height-1)/2, 6) + menuHeight := max(height-topHeight, 6) party := strings.Join([]string{ m.theme.PanelTitle.Render("PARTY"), @@ -271,10 +274,10 @@ func (m Model) partyDashboardView() string { }, "\n") return lipgloss.JoinHorizontal(lipgloss.Top, - m.panel(panelWidth).Render(party), + m.panelWithHeight(panelWidth, topHeight).Render(party), " ", - m.panel(panelWidth).Render(quest), - ) + "\n" + m.panel(width).Render(menu) + m.panelWithHeight(panelWidth, topHeight).Render(quest), + ) + "\n" + m.panelWithHeight(width, menuHeight).Render(menu) } func (m Model) terminalDashboardView() string { @@ -290,11 +293,12 @@ func (m Model) terminalDashboardView() string { m.row(2, "LOAD PROFILE ROSTER"), m.row(3, "ASK CONFLICT ORACLE"), } - return m.panel(m.availableWidth()).Render(strings.Join(rows, "\n")) + return m.panelWithHeight(m.availableWidth(), m.availableContentHeight()).Render(strings.Join(rows, "\n")) } func (m Model) commanderDashboardView() string { width := m.availableWidth() + height := m.availableContentHeight() gap := 1 leftWidth := max((width-gap)/2, 24) rightWidth := max(width-gap-leftWidth, 24) @@ -316,9 +320,9 @@ func (m Model) commanderDashboardView() string { }, "\n") return lipgloss.JoinHorizontal(lipgloss.Top, - m.panel(leftWidth).Render(left), + m.panelWithHeight(leftWidth, height).Render(left), " ", - m.panel(rightWidth).Render(right), + m.panelWithHeight(rightWidth, height).Render(right), ) } @@ -335,7 +339,7 @@ func (m Model) crtDashboardView() string { m.row(2, "Profiles"), m.row(3, "Consult Conflict Oracle"), } - return m.panel(m.availableWidth()).Render(strings.Join(rows, "\n")) + return m.panelWithHeight(m.availableWidth(), m.availableContentHeight()).Render(strings.Join(rows, "\n")) } func (m Model) modsView() string { @@ -344,7 +348,7 @@ func (m Model) modsView() string { for i, mod := range m.data.InstalledMods { rows = append(rows, m.modRow(i, mod)) } - return m.panel(m.availableWidth()).Render(strings.Join(rows, "\n")) + return m.panelWithHeight(m.availableWidth(), m.availableContentHeight()).Render(strings.Join(rows, "\n")) } func (m Model) searchView() string { @@ -353,7 +357,7 @@ func (m Model) searchView() string { for i, mod := range m.data.SearchResults { rows = append(rows, m.modRow(i, mod)) } - return m.panel(m.availableWidth()).Render(strings.Join(rows, "\n")) + return m.panelWithHeight(m.availableWidth(), m.availableContentHeight()).Render(strings.Join(rows, "\n")) } func (m Model) profilesView() string { @@ -366,7 +370,7 @@ func (m Model) profilesView() string { line := fmt.Sprintf("%s %-22s %3d mods", active, profile.Name, profile.ModCount) rows = append(rows, m.row(i, line)) } - return m.panel(min(m.availableWidth(), 54)).Render(strings.Join(rows, "\n")) + return m.panelWithHeight(m.availableWidth(), m.availableContentHeight()).Render(strings.Join(rows, "\n")) } func (m Model) helpView() string { @@ -398,7 +402,11 @@ func (m Model) modRow(index int, mod prototype.Mod) string { } func (m Model) panel(width int) lipgloss.Style { - return m.theme.Panel.Width(max(width-m.theme.Panel.GetHorizontalFrameSize(), 1)) + return m.theme.Panel.Width(max(width-m.theme.Panel.GetHorizontalBorderSize(), 1)) +} + +func (m Model) panelWithHeight(width, height int) lipgloss.Style { + return m.panel(width).Height(max(height-m.theme.Panel.GetVerticalBorderSize(), 1)) } func (m Model) availableWidth() int { @@ -408,6 +416,15 @@ func (m Model) availableWidth() int { return max(m.width-m.theme.App.GetHorizontalFrameSize(), 40) } +func (m Model) availableContentHeight() int { + if m.height == 0 { + return 12 + } + + const chromeHeight = 6 // title, nav, spacer lines, and one-line footer. + return max(m.height-m.theme.App.GetVerticalFrameSize()-chromeHeight, 8) +} + func layoutForTheme(name string) Layout { switch name { case "amber": @@ -425,13 +442,6 @@ func statusValue(value int, color lipgloss.Color) string { return lipgloss.NewStyle().Foreground(color).Bold(true).Render(fmt.Sprintf("%d", value)) } -func min(a, b int) int { - if a < b { - return a - } - return b -} - func max(a, b int) int { if a > b { return a diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index ac0d16b..738e423 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -139,6 +139,36 @@ func TestWindowSizeExpandsViewToTerminalBounds(t *testing.T) { require.Equal(t, 30, lipgloss.Height(view)) } +func TestScreenViewsUseAvailableWidth(t *testing.T) { + t.Parallel() + + model, err := NewPrototypeModel(Options{Theme: "wizardry", Prototype: true}) + require.NoError(t, err) + + updated, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 36}) + model = updated.(Model) + + for _, screen := range screens { + model.screen = screen + require.Equal(t, model.availableWidth(), lipgloss.Width(model.screenView()), screen.String()) + } +} + +func TestScreenViewsUseAvailableHeightOnLargeTerminals(t *testing.T) { + t.Parallel() + + model, err := NewPrototypeModel(Options{Theme: "wizardry", Prototype: true}) + require.NoError(t, err) + + updated, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 36}) + model = updated.(Model) + + for _, screen := range screens { + model.screen = screen + require.GreaterOrEqual(t, lipgloss.Height(model.screenView()), model.availableContentHeight(), screen.String()) + } +} + func TestThemesUseDistinctLayouts(t *testing.T) { t.Parallel() From 962be5fc69fd7dd71442ca18ade7b0eb2cedd028 Mon Sep 17 00:00:00 2001 From: "Donovan C. Young" Date: Wed, 29 Apr 2026 19:48:29 -0400 Subject: [PATCH 2/5] fix(tui): avoid painting default chrome backgrounds --- CHANGELOG.md | 2 +- internal/tui/theme/theme.go | 10 ++-------- internal/tui/theme/theme_test.go | 22 ++++++++++++++++++++++ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b8983..d84b06c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Prototype TUI visual pass**: The TUI now expands to the current terminal size, uses more readable muted colors on dark terminals, assigns each retro theme its own dashboard layout rather than only swapping colors, and stretches screen panels/sections to use the available content area instead of rendering as fixed-size islands. +- **Prototype TUI visual pass**: The TUI now expands to the current terminal size, uses more readable muted colors on dark terminals, assigns each retro theme its own dashboard layout rather than only swapping colors, stretches screen panels/sections to use the available content area instead of rendering as fixed-size islands, and avoids painting non-selected chrome backgrounds so terminal themes do not show stray ANSI blocks. ## [1.3.10] - 2026-04-26 diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go index 67cc2ee..5c631ac 100644 --- a/internal/tui/theme/theme.go +++ b/internal/tui/theme/theme.go @@ -61,21 +61,17 @@ func base(name string, foreground, background, accent lipgloss.Color) Theme { Success: success, App: lipgloss.NewStyle(). Foreground(foreground). - Background(background). Padding(1, 2), Title: lipgloss.NewStyle(). Foreground(accent). - Background(background). Bold(true), Panel: lipgloss.NewStyle(). Foreground(foreground). - Background(background). Border(lipgloss.RoundedBorder()). BorderForeground(accent). Padding(0, 1), PanelTitle: lipgloss.NewStyle(). Foreground(accent). - Background(background). Bold(true), Selected: lipgloss.NewStyle(). Foreground(background). @@ -94,11 +90,9 @@ func base(name string, foreground, background, accent lipgloss.Color) Theme { func (t Theme) withMuted(muted lipgloss.Color) Theme { t.Muted = muted t.MutedText = lipgloss.NewStyle(). - Foreground(muted). - Background(t.Background) + Foreground(muted) t.Help = lipgloss.NewStyle(). - Foreground(muted). - Background(t.Background) + Foreground(muted) return t } diff --git a/internal/tui/theme/theme_test.go b/internal/tui/theme/theme_test.go index 23ea9d1..3548aa0 100644 --- a/internal/tui/theme/theme_test.go +++ b/internal/tui/theme/theme_test.go @@ -40,6 +40,22 @@ func TestByNameRejectsUnknownTheme(t *testing.T) { require.Error(t, err) } +func TestThemeChromeUsesTerminalDefaultBackground(t *testing.T) { + t.Parallel() + + theme, err := ByName("wizardry") + require.NoError(t, err) + + assertNoBackground(t, theme.App) + assertNoBackground(t, theme.Title) + assertNoBackground(t, theme.Panel) + assertNoBackground(t, theme.PanelTitle) + assertNoBackground(t, theme.MutedText) + assertNoBackground(t, theme.Help) + + require.IsType(t, lipgloss.Color(""), theme.Selected.GetBackground()) +} + func TestDarkTerminalThemesKeepMutedTextReadable(t *testing.T) { t.Parallel() @@ -53,6 +69,12 @@ func TestDarkTerminalThemesKeepMutedTextReadable(t *testing.T) { } } +func assertNoBackground(t *testing.T, style lipgloss.Style) { + t.Helper() + + require.IsType(t, lipgloss.NoColor{}, style.GetBackground()) +} + func colorIndex(t *testing.T, color lipgloss.Color) int { t.Helper() From 692d4cbef8be6f1c591ca03020bb8a4855ce427e Mon Sep 17 00:00:00 2001 From: "Donovan C. Young" Date: Wed, 29 Apr 2026 20:01:37 -0400 Subject: [PATCH 3/5] docs(tui): record wizardry as default theme --- CHANGELOG.md | 2 +- docs/plans/2026-04-28-tui-implementation.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d84b06c..18529d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Prototype TUI visual pass**: The TUI now expands to the current terminal size, uses more readable muted colors on dark terminals, assigns each retro theme its own dashboard layout rather than only swapping colors, stretches screen panels/sections to use the available content area instead of rendering as fixed-size islands, and avoids painting non-selected chrome backgrounds so terminal themes do not show stray ANSI blocks. +- **Prototype TUI visual pass**: The TUI now expands to the current terminal size, uses more readable muted colors on dark terminals, assigns each retro theme its own dashboard layout rather than only swapping colors, stretches screen panels/sections to use the available content area instead of rendering as fixed-size islands, avoids painting non-selected chrome backgrounds so terminal themes do not show stray ANSI blocks, and documents `wizardry` as the selected default theme while retaining `amber`, `dos`, and `green` as alternates. ## [1.3.10] - 2026-04-26 diff --git a/docs/plans/2026-04-28-tui-implementation.md b/docs/plans/2026-04-28-tui-implementation.md index 62342cb..4cdc771 100644 --- a/docs/plans/2026-04-28-tui-implementation.md +++ b/docs/plans/2026-04-28-tui-implementation.md @@ -314,7 +314,9 @@ lmm tui --prototype --theme dos 5. Keep alternate themes available if they are cheap to maintain. 6. Document the decision in this plan or a short follow-up note. -**Recommended default:** `wizardry`, with `amber` and `dos` retained as alternates if maintenance stays low. +**Selected default:** `wizardry`. It has the strongest RPG/tool identity for `lmm` and best matches the “Wizardry/Ultima in spirit, useful mod manager in practice” direction. + +**Retained alternates:** `amber`, `dos`, and `green` stay available while they remain cheap to maintain. `amber` is explicitly kept for the VAX/VMS-era terminal nostalgia lane. **Exit criteria:** From 7a3b2a8ec399f146b6c4437e031b7d253dde235a Mon Sep 17 00:00:00 2001 From: "Donovan C. Young" Date: Wed, 29 Apr 2026 20:03:37 -0400 Subject: [PATCH 4/5] docs(tui): note configurable default theme --- CHANGELOG.md | 2 +- docs/plans/2026-04-28-tui-implementation.md | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18529d3..3778fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Prototype TUI visual pass**: The TUI now expands to the current terminal size, uses more readable muted colors on dark terminals, assigns each retro theme its own dashboard layout rather than only swapping colors, stretches screen panels/sections to use the available content area instead of rendering as fixed-size islands, avoids painting non-selected chrome backgrounds so terminal themes do not show stray ANSI blocks, and documents `wizardry` as the selected default theme while retaining `amber`, `dos`, and `green` as alternates. +- **Prototype TUI visual pass**: The TUI now expands to the current terminal size, uses more readable muted colors on dark terminals, assigns each retro theme its own dashboard layout rather than only swapping colors, stretches screen panels/sections to use the available content area instead of rendering as fixed-size islands, avoids painting non-selected chrome backgrounds so terminal themes do not show stray ANSI blocks, documents `wizardry` as the selected default theme while retaining `amber`, `dos`, and `green` as alternates, and records that the implementation phase should let users configure their own default TUI theme. ## [1.3.10] - 2026-04-26 diff --git a/docs/plans/2026-04-28-tui-implementation.md b/docs/plans/2026-04-28-tui-implementation.md index 4cdc771..befe2ec 100644 --- a/docs/plans/2026-04-28-tui-implementation.md +++ b/docs/plans/2026-04-28-tui-implementation.md @@ -336,18 +336,21 @@ lmm tui --prototype --theme dos 2. Implement a real adapter over `*core.Service`. 3. Keep a fake adapter for tests and prototype/demo mode. 4. Wire `lmm tui` without `--prototype` to initialize config and service like existing CLI commands. -5. Load real data for: +5. Load the user's configured default TUI theme when no `--theme` flag is provided, while still defaulting fresh installs to `wizardry`. +6. Add a config path for setting the default TUI theme, analogous to the existing default game workflow, so users can make `amber`, `dos`, `green`, or future themes their personal default. +7. Load real data for: - current/configured games - active/default profile - installed mods - profile list - status/update/conflict summaries where existing core methods support it -6. Preserve `--prototype` as a safe design/demo mode. -7. Add loading/error states for missing config, missing game, auth-required, and empty mod lists. +8. Preserve `--prototype` as a safe design/demo mode. +9. Add loading/error states for missing config, missing game, auth-required, and empty mod lists. **Exit criteria:** - `lmm tui` starts with real config/service initialization. +- A user-configured default TUI theme is honored when `--theme` is omitted; `--theme` remains an explicit per-run override. - Dashboard and Installed Mods views show real local data. - Search/Profile views either show real read-only data or honest placeholder states. - `go test ./...` passes. From 7f4df6102445975fc387267df97f781b2900105d Mon Sep 17 00:00:00 2001 From: "Donovan C. Young" Date: Wed, 29 Apr 2026 20:11:37 -0400 Subject: [PATCH 5/5] fix(tui): keep prototype layouts within bounds --- internal/tui/app.go | 24 +++++++++++++------ internal/tui/app_test.go | 51 +++++++++++++++++++++++++++++++--------- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 705d680..e2048e3 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -247,9 +247,10 @@ func (m Model) partyDashboardView() string { width := m.availableWidth() height := m.availableContentHeight() gap := 1 - panelWidth := max((width-gap)/2, 24) - topHeight := max((height-1)/2, 6) - menuHeight := max(height-topHeight, 6) + panelWidth := max((width-gap)/2, 1) + splitHeight := height + topHeight := splitHeight / 2 + menuHeight := splitHeight - topHeight party := strings.Join([]string{ m.theme.PanelTitle.Render("PARTY"), @@ -300,8 +301,8 @@ func (m Model) commanderDashboardView() string { width := m.availableWidth() height := m.availableContentHeight() gap := 1 - leftWidth := max((width-gap)/2, 24) - rightWidth := max(width-gap-leftWidth, 24) + leftWidth := max((width-gap)/2, 1) + rightWidth := max(width-gap-leftWidth, 1) left := strings.Join([]string{ m.theme.PanelTitle.Render("ACTIVE PROFILE"), @@ -421,8 +422,17 @@ func (m Model) availableContentHeight() int { return 12 } - const chromeHeight = 6 // title, nav, spacer lines, and one-line footer. - return max(m.height-m.theme.App.GetVerticalFrameSize()-chromeHeight, 8) + return max(m.height-m.theme.App.GetVerticalFrameSize()-m.contentChromeHeight(), 8) +} + +func (m Model) contentChromeHeight() int { + footerHeight := 1 + if m.showHelp { + footerHeight = lipgloss.Height(m.helpView()) + } + + const titleNavAndSpacerHeight = 4 // title, nav, and the spacer lines around content. + return titleNavAndSpacerHeight + footerHeight } func layoutForTheme(name string) Layout { diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 738e423..67568ef 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -142,11 +142,7 @@ func TestWindowSizeExpandsViewToTerminalBounds(t *testing.T) { func TestScreenViewsUseAvailableWidth(t *testing.T) { t.Parallel() - model, err := NewPrototypeModel(Options{Theme: "wizardry", Prototype: true}) - require.NoError(t, err) - - updated, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 36}) - model = updated.(Model) + model := sizedPrototypeModel(t, "wizardry", 120, 36) for _, screen := range screens { model.screen = screen @@ -154,21 +150,42 @@ func TestScreenViewsUseAvailableWidth(t *testing.T) { } } -func TestScreenViewsUseAvailableHeightOnLargeTerminals(t *testing.T) { +func TestDashboardLayoutsDoNotOverflowNarrowTerminals(t *testing.T) { t.Parallel() - model, err := NewPrototypeModel(Options{Theme: "wizardry", Prototype: true}) - require.NoError(t, err) + for _, themeName := range []string{"wizardry", "dos"} { + t.Run(themeName, func(t *testing.T) { + t.Parallel() - updated, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 36}) - model = updated.(Model) + model := sizedPrototypeModel(t, themeName, 40, 24) + require.Equal(t, ScreenDashboard, model.CurrentScreen()) + require.LessOrEqual(t, lipgloss.Width(model.screenView()), model.availableWidth()) + }) + } +} + +func TestScreenViewsUseExactAvailableHeightOnLargeTerminals(t *testing.T) { + t.Parallel() + + model := sizedPrototypeModel(t, "wizardry", 120, 36) for _, screen := range screens { model.screen = screen - require.GreaterOrEqual(t, lipgloss.Height(model.screenView()), model.availableContentHeight(), screen.String()) + require.Equal(t, model.availableContentHeight(), lipgloss.Height(model.screenView()), screen.String()) } } +func TestViewFitsTerminalBoundsWithHelpVisible(t *testing.T) { + t.Parallel() + + model := sizedPrototypeModel(t, "wizardry", 120, 36) + model = updateWithRunes(t, model, "?") + + view := model.View() + require.Equal(t, 120, lipgloss.Width(view)) + require.Equal(t, 36, lipgloss.Height(view)) +} + func TestThemesUseDistinctLayouts(t *testing.T) { t.Parallel() @@ -191,6 +208,18 @@ func TestThemesUseDistinctLayouts(t *testing.T) { } } +func sizedPrototypeModel(t *testing.T, themeName string, width, height int) Model { + t.Helper() + + model, err := NewPrototypeModel(Options{Theme: themeName, Prototype: true}) + require.NoError(t, err) + + updated, _ := model.Update(tea.WindowSizeMsg{Width: width, Height: height}) + updatedModel, ok := updated.(Model) + require.True(t, ok) + return updatedModel +} + func updateWithRunes(t *testing.T, model Model, key string) Model { t.Helper()