commit 66f6579dce2379d7e3805a7452f3a43d23d9b3fa
parent 4c83ff3b86587ff7c0f42378842ff127c2add16a
Author: Damian Myrda <damian@prime8.dev>
Date: Wed, 21 Oct 2026 08:00:24 +0000
feat(tui): interactive shell on the about page
The prompt at the bottom of the about page now takes input. It runs
against a read-only in-memory fs generated from the site content:
~/{about,bio,contact}
~/{interests,hobbies,languages}/
~/experience/<id>
~/projects/<id>/{README.md,info}
Supports ls, cd, pwd, cat, clear, help, exit (plus whoami, echo), tab
completion, ctrl+u/ctrl+w/ctrl+l/ctrl+d. Letters always go to the
prompt; with an empty prompt, 1-3, tab, ? and the arrows keep their
usual bindings.
The shell, prompts and window title use the ssh session user,
sanitized to a safe path segment ([a-z0-9._-], max 32 chars,
fallback "visitor"), for whoami, sudo and /home/<user>.
Diffstat:
24 files changed, 1618 insertions(+), 55 deletions(-)
diff --git a/cmd/server/handlers/curl.go b/cmd/server/handlers/curl.go
@@ -7,10 +7,10 @@ import (
"strings"
"sync"
"time"
- "unicode/utf8"
"git.prime8.dev/moncheeta/prime8dev/internal/config"
"git.prime8.dev/moncheeta/prime8dev/internal/utils"
+ "github.com/charmbracelet/x/ansi"
)
const (
@@ -22,7 +22,7 @@ const (
ansiBlue = "\x1b[38;5;110m"
)
-var curlLogo = sync.OnceValue(func() string { return utils.LogoASCII(curlLogoWidth, false) })
+var curlLogo = sync.OnceValue(func() string { return utils.LogoASCII(curlLogoWidth, true) })
// isTerminalClient reports whether the request comes from a CLI http client.
func isTerminalClient(r *http.Request) bool {
@@ -84,7 +84,8 @@ func terminalFetch(about config.About, job *config.Experience) string {
if i < len(logo) {
l = logo[i]
}
- b.WriteString(l + strings.Repeat(" ", max(0, curlLogoWidth-utf8.RuneCountInString(l))) + " ")
+ // pad by visible width: the logo lines carry truecolor escapes
+ b.WriteString(l + ansiReset + strings.Repeat(" ", max(0, curlLogoWidth-ansi.StringWidth(l))) + " ")
if i < len(lines) {
b.WriteString(lines[i])
}
diff --git a/cmd/server/handlers/curl_test.go b/cmd/server/handlers/curl_test.go
@@ -0,0 +1,36 @@
+package handlers
+
+import (
+ "os"
+ "strings"
+ "testing"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ "github.com/charmbracelet/x/ansi"
+)
+
+func TestTerminalFetchColoredLogoAligned(t *testing.T) {
+ if err := os.Chdir("../../.."); err != nil {
+ t.Fatal(err)
+ }
+ out := terminalFetch(config.GetAbout(), config.CurrentJob(config.GetExperience()))
+ // aic picks its color level from COLORTERM at init (the Docker image sets
+ // COLORTERM=truecolor); without it the logo is plain but must still align.
+ if strings.Contains(os.Getenv("COLORTERM"), "truecolor") && !strings.Contains(out, "\x1b[38;2;") {
+ t.Fatal("logo is not truecolor")
+ }
+ lines := strings.Split(strings.Trim(out, "\n"), "\n")
+ for i, l := range lines {
+ plain := ansi.Strip(l)
+ if ansi.StringWidth(plain) <= curlLogoWidth {
+ continue
+ }
+ // the info column starts right after the padded logo and 3 spaces
+ if got := []rune(plain)[curlLogoWidth : curlLogoWidth+3]; string(got) != " " {
+ t.Errorf("line %d misaligned: %q", i, plain)
+ }
+ }
+ if !strings.Contains(ansi.Strip(lines[0]), "moncheeta@prime8") {
+ t.Errorf("first line missing title: %q", ansi.Strip(lines[0]))
+ }
+}
diff --git a/cmd/server/handlers/wakatime.go b/cmd/server/handlers/wakatime.go
@@ -3,10 +3,16 @@ package handlers
import (
"encoding/json"
"net/http"
+
+ "git.prime8.dev/moncheeta/prime8dev/internal/config"
)
func (h *Handler) WakaTimeHandler(w http.ResponseWriter, r *http.Request) {
- stats, err := h.wakaTimeService.FetchStats()
+ fetch := h.wakaTimeService.FetchTotal
+ if config.WakaTimeWeeklyEnabled {
+ fetch = h.wakaTimeService.FetchStats
+ }
+ stats, err := fetch()
if err != nil {
http.Error(w, "Failed to fetch stats", http.StatusInternalServerError)
return
diff --git a/go.mod b/go.mod
@@ -16,6 +16,7 @@ require (
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894
github.com/charmbracelet/wish v1.4.7
+ github.com/charmbracelet/x/ansi v0.10.1
github.com/evanw/esbuild v0.27.4
github.com/joho/godotenv v1.5.1
github.com/muesli/termenv v0.16.0
@@ -31,7 +32,6 @@ require (
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/keygen v0.5.3 // indirect
github.com/charmbracelet/log v0.4.1 // indirect
- github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/conpty v0.1.0 // indirect
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
diff --git a/internal/config/site.go b/internal/config/site.go
@@ -5,5 +5,10 @@ const (
SiteURL = "https://" + SiteDomain
)
-// WakaTimeEnabled toggles the WakaTime stats on the web and SSH frontends.
-const WakaTimeEnabled = false
+// WakaTimeEnabled toggles the all-time WakaTime total ("N+ hrs coded") in the
+// web status bar and the SSH TUI footer.
+const WakaTimeEnabled = true
+
+// WakaTimeWeeklyEnabled toggles the daily breakdown (wakatime --week) on the
+// about page. Off: only the all-time total is shown.
+const WakaTimeWeeklyEnabled = false
diff --git a/internal/external/wakatime.go b/internal/external/wakatime.go
@@ -66,6 +66,36 @@ func (s *WakaTime) FetchStats() (*WakaTimeStats, error) {
return stats, nil
}
+// FetchTotal returns only the all-time total ("N+ hrs coded"), skipping the
+// daily/weekly endpoints. It returns nil when no API key is configured.
+func (s *WakaTime) FetchTotal() (*WakaTimeStats, error) {
+ if s.apiKey == "" {
+ return nil, nil
+ }
+
+ cacheKey := "wakatime:total"
+ if val, found := s.cache.Get(cacheKey); found {
+ return val.(*WakaTimeStats), nil
+ }
+
+ resp, err := s.makeRequest("/users/current/all_time_since_today")
+ if err != nil {
+ return nil, err
+ }
+ var data allTimeResponse
+ if err := json.Unmarshal(resp, &data); err != nil {
+ return nil, err
+ }
+
+ stats := &WakaTimeStats{AllTime: AllTimeStats{TotalText: formatTotalHours(data.Data.TotalSeconds)}}
+ s.cache.Set(cacheKey, stats, 5*time.Minute)
+ return stats, nil
+}
+
+func formatTotalHours(totalSeconds float64) string {
+ return fmt.Sprintf("%d+ hrs coded", int(totalSeconds/3600))
+}
+
type allTimeResponse struct {
Data struct {
TotalSeconds float64 `json:"total_seconds"`
@@ -140,8 +170,7 @@ func (s *WakaTime) fetchAllTimeStats() (*AllTimeStats, error) {
if stats != nil {
totalSeconds = stats.Data.TotalSeconds
}
- totalHours := int(totalSeconds / 3600)
- totalText := fmt.Sprintf("%d+ hrs coded", totalHours)
+ totalText := formatTotalHours(totalSeconds)
dailyAvgText := "0 mins"
if langStats != nil && langStats.Data.DailyAverageIncludingOtherLanguage > 0 {
diff --git a/internal/external/wakatime_test.go b/internal/external/wakatime_test.go
@@ -0,0 +1,41 @@
+package external
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/patrickmn/go-cache"
+)
+
+func TestFetchTotalOnlyHitsAllTime(t *testing.T) {
+ var paths []string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ paths = append(paths, r.URL.Path)
+ _, _ = w.Write([]byte(`{"data":{"total_seconds":3603600}}`))
+ }))
+ defer srv.Close()
+
+ w := &WakaTime{httpClient: srv.Client(), baseURL: srv.URL, apiKey: "k", cache: cache.New(time.Minute, time.Minute)}
+ stats, err := w.FetchTotal()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stats.AllTime.TotalText != "1001+ hrs coded" {
+ t.Fatalf("total = %q", stats.AllTime.TotalText)
+ }
+ if stats.AllTime.DailyAverageText != "" || len(stats.Weekly.Days) != 0 {
+ t.Fatalf("daily stats leaked: %+v", stats)
+ }
+ if len(paths) != 1 || paths[0] != "/users/current/all_time_since_today" {
+ t.Fatalf("requests = %v", paths)
+ }
+}
+
+func TestFetchTotalWithoutKey(t *testing.T) {
+ w := &WakaTime{apiKey: "", cache: cache.New(time.Minute, time.Minute)}
+ if stats, err := w.FetchTotal(); stats != nil || err != nil {
+ t.Fatalf("got %v, %v", stats, err)
+ }
+}
diff --git a/internal/tui/content.go b/internal/tui/content.go
@@ -12,22 +12,26 @@ import (
)
func (m portfolioModel) aboutDetail() string {
+ shellW, _ := m.detailSize()
+ if m.shell.cleared {
+ return m.shellView(shellW)
+ }
parts := []string{
m.aboutFetch(),
"",
- renderPrompt(m.about.Handle, "cat bio"),
+ renderPrompt(m.user, "cat bio"),
m.about.Bio,
"",
- renderPrompt(m.about.Handle, "ls interests/"),
+ renderPrompt(m.user, "ls interests/"),
renderTagList(m.about.Interests),
"",
- renderPrompt(m.about.Handle, "ls hobbies/"),
+ renderPrompt(m.user, "ls hobbies/"),
renderTagList(m.about.Hobbies),
"",
- renderPrompt(m.about.Handle, "") + rowStyle.Render("█"),
+ m.shellView(shellW),
}
- if appconfig.WakaTimeEnabled {
- parts = append(parts, "", renderPrompt(m.about.Handle, "wakatime --week"), m.aboutWeeklyStats())
+ if appconfig.WakaTimeEnabled && appconfig.WakaTimeWeeklyEnabled {
+ parts = append(parts, "", renderPrompt(m.user, "wakatime --week"), m.aboutWeeklyStats())
}
return strings.Join(parts, "\n")
@@ -271,7 +275,7 @@ func (m portfolioModel) experiencePage() string {
return "No experience available."
}
- rows := []string{renderPrompt(m.about.Handle, "git log --oneline --graph --decorate")}
+ rows := []string{renderPrompt(m.user, "git log --oneline --graph --decorate")}
for i, e := range m.experience {
decorator := ""
if e.Current {
diff --git a/internal/tui/hscroll.go b/internal/tui/hscroll.go
@@ -0,0 +1,61 @@
+package tui
+
+import (
+ "strings"
+
+ "github.com/charmbracelet/x/ansi"
+)
+
+const detailHorizontalStep = 8
+
+var cutMarkerStyle = dimStyle
+
+func lineWidths(content string) []int {
+ lines := strings.Split(content, "\n")
+ widths := make([]int, len(lines))
+ for i, l := range lines {
+ widths[i] = ansi.StringWidth(l)
+ }
+ return widths
+}
+
+func (m portfolioModel) detailMaxXOffset() int {
+ longest := 0
+ for _, w := range m.detailLineWidths {
+ longest = max(longest, w)
+ }
+ return max(longest-m.viewport.Width, 0)
+}
+
+// scrollDetailX pans the detail viewport sideways; only wide preformatted
+// blocks are wider than the pane, so prose never moves out of view for long.
+func (m *portfolioModel) scrollDetailX(delta int) {
+ m.detailXOffset = min(max(m.detailXOffset+delta, 0), m.detailMaxXOffset())
+ m.viewport.SetXOffset(m.detailXOffset)
+}
+
+// detailViewWithCutMarkers renders the viewport and marks lines that continue
+// past the right edge with › (and past the left edge with ‹ when panned).
+func (m portfolioModel) detailViewWithCutMarkers() string {
+ view := m.viewport.View()
+ w := m.viewport.Width
+ if w < 4 || m.detailMaxXOffset() == 0 {
+ return view
+ }
+ lines := strings.Split(view, "\n")
+ for i, line := range lines {
+ idx := m.viewport.YOffset + i
+ if idx >= len(m.detailLineWidths) {
+ break
+ }
+ full := m.detailLineWidths[idx]
+ if full > m.detailXOffset+w {
+ line = ansi.Truncate(line, w-1, "") + cutMarkerStyle.Render("›")
+ }
+ if m.detailXOffset > 0 && full > m.detailXOffset {
+ line = cutMarkerStyle.Render("‹") + ansi.Cut(line, 1, w)
+ }
+ lines[i] = line
+ }
+ return strings.Join(lines, "\n")
+}
diff --git a/internal/tui/hscroll_test.go b/internal/tui/hscroll_test.go
@@ -0,0 +1,207 @@
+package tui
+
+import (
+ "strings"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/x/ansi"
+)
+
+const wideReadme = "# janitor\n\nsome prose that is long enough to wrap around the pane width for sure, yes indeed it is long. " +
+ "some prose that is long enough to wrap around the pane width for sure, yes indeed it is long.\n\n" +
+ "```\n" +
+ "+--------+ +-------------------------+ +--------------------+ +----------+ +-------+ END\n" +
+ "| client | ---> | server with a long name | ---> | queue | ---> | db |\n" +
+ "```\n\nnarrow:\n\n```\nok\n```\n"
+
+func TestWideBlocksDoNotWrap(t *testing.T) {
+ out := ansi.Strip(renderMarkdown(wideReadme, 40))
+ if !strings.Contains(out, "┃ +--------+ +-------------------------+ +--------------------+ +----------+ +-------+ END") {
+ t.Fatalf("wide block wrapped:\n%s", out)
+ }
+ for _, l := range strings.Split(out, "\n") {
+ if strings.Contains(l, "prose") && ansi.StringWidth(l) > 40 {
+ t.Fatalf("prose did not wrap: %q", l)
+ }
+ }
+ if !strings.Contains(out, "┃ ok") {
+ t.Fatalf("narrow block lost:\n%s", out)
+ }
+ if strings.Contains(out, wideBlockToken) {
+ t.Fatalf("placeholder leaked:\n%s", out)
+ }
+}
+
+func TestIndentedListContinuationIsNotCode(t *testing.T) {
+ md := "- item\n\n continuation of the list item that is quite long and keeps going past forty columns\n"
+ src, blocks := extractWideBlocks(md, 40)
+ if len(blocks) != 0 || src != md {
+ t.Fatalf("list continuation extracted: %q %v", src, blocks)
+ }
+}
+
+func TestReadmeHorizontalScroll(t *testing.T) {
+ m := testModel(t, 100, 40)
+ id := m.projects[0].ID
+ m.projectReadmes[id] = wideReadme
+ m.activateTab(tabProjects)
+ w := m.projectMarkdownWidth()
+ rendered := renderProjectMarkdownCmd(id, replaceFirstH1WithSourceLink(wideReadme, "janitor", ""), "", w)().(projectMarkdownRenderedMsg)
+ next, _ := m.Update(rendered)
+ m = next.(portfolioModel)
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyEnter})
+
+ view := ansi.Strip(m.View())
+ if !strings.Contains(view, "›") {
+ t.Fatalf("no right cut marker:\n%s", view)
+ }
+ if strings.Contains(view, "END") {
+ t.Fatal("END visible before scrolling")
+ }
+ for i := 0; i < 10; i++ {
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}})
+ }
+ view = ansi.Strip(m.View())
+ if !strings.Contains(view, "END") || !strings.Contains(view, "‹") {
+ t.Fatalf("scrolling right did not reveal END:\n%s", view)
+ }
+ for _, l := range strings.Split(view, "\n") {
+ if ansi.StringWidth(l) > 100 {
+ t.Fatalf("line overflows: %d", ansi.StringWidth(l))
+ }
+ }
+ for i := 0; i < 20; i++ {
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}})
+ }
+ if m.detailXOffset != 0 {
+ t.Fatalf("x offset = %d", m.detailXOffset)
+ }
+ // left still leaves readme focus
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyLeft})
+ if m.projectReadmeScroll {
+ t.Fatal("left no longer backs out of the readme")
+ }
+}
+
+// readmeModel returns a model on the projects tab with the wide readme
+// rendered and the readme pane focused.
+func readmeModel(t *testing.T) portfolioModel {
+ t.Helper()
+ m := testModel(t, 100, 40)
+ id := m.projects[0].ID
+ m.projectReadmes[id] = wideReadme
+ m.activateTab(tabProjects)
+ w := m.projectMarkdownWidth()
+ rendered := renderProjectMarkdownCmd(id, replaceFirstH1WithSourceLink(wideReadme, "janitor", ""), "", w)().(projectMarkdownRenderedMsg)
+ m = send(t, m, rendered)
+ m = send(t, m, tea.KeyMsg{Type: tea.KeyEnter})
+ if !m.projectReadmeScroll {
+ t.Fatal("enter did not focus the readme")
+ }
+ if m.detailMaxXOffset() <= 0 {
+ t.Fatalf("detailMaxXOffset = %d for a wide code block", m.detailMaxXOffset())
+ }
+ return m
+}
+
+func send(t *testing.T, m portfolioModel, msg tea.Msg) portfolioModel {
+ t.Helper()
+ next, _ := m.Update(msg)
+ return next.(portfolioModel)
+}
+
+func TestReadmeArrowKeysScrollSideways(t *testing.T) {
+ m := readmeModel(t)
+ maxX := m.detailMaxXOffset()
+
+ m = send(t, m, tea.KeyMsg{Type: tea.KeyRight})
+ if m.detailXOffset != detailHorizontalStep || m.activeTab != tabProjects {
+ t.Fatalf("right: x=%d tab=%d", m.detailXOffset, m.activeTab)
+ }
+ for i := 0; i < 50; i++ {
+ m = send(t, m, tea.KeyMsg{Type: tea.KeyRight})
+ }
+ if m.detailXOffset != maxX || m.activeTab != tabProjects || !m.projectReadmeScroll {
+ t.Fatalf("right to the end: x=%d (max %d) tab=%d focus=%v", m.detailXOffset, maxX, m.activeTab, m.projectReadmeScroll)
+ }
+ if view := ansi.Strip(m.View()); !strings.Contains(view, "END") {
+ t.Fatalf("END not visible at max offset:\n%s", view)
+ }
+
+ m = send(t, m, tea.KeyMsg{Type: tea.KeyLeft})
+ if m.detailXOffset != maxX-detailHorizontalStep || m.activeTab != tabProjects || !m.projectReadmeScroll {
+ t.Fatalf("left: x=%d tab=%d focus=%v", m.detailXOffset, m.activeTab, m.projectReadmeScroll)
+ }
+ for m.detailXOffset > 0 {
+ m = send(t, m, tea.KeyMsg{Type: tea.KeyLeft})
+ if m.activeTab != tabProjects || !m.projectReadmeScroll {
+ t.Fatalf("left while panned changed tab/focus: x=%d tab=%d", m.detailXOffset, m.activeTab)
+ }
+ }
+ // left at the left edge backs out of the readme, still without a tab change
+ m = send(t, m, tea.KeyMsg{Type: tea.KeyLeft})
+ if m.projectReadmeScroll || m.activeTab != tabProjects {
+ t.Fatalf("left at x=0: focus=%v tab=%d", m.projectReadmeScroll, m.activeTab)
+ }
+ // with the readme unfocused, arrows switch tabs again
+ m = send(t, m, tea.KeyMsg{Type: tea.KeyRight})
+ if m.activeTab != tabAbout {
+ t.Fatalf("right outside the readme: tab=%d", m.activeTab)
+ }
+}
+
+func TestReadmeHLAndShiftArrowsScrollEndToEnd(t *testing.T) {
+ for _, keys := range []struct {
+ name string
+ right, left tea.KeyMsg
+ }{
+ {"h/l", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}},
+ {"shift+arrows", tea.KeyMsg{Type: tea.KeyShiftRight}, tea.KeyMsg{Type: tea.KeyShiftLeft}},
+ } {
+ m := readmeModel(t)
+ maxX := m.detailMaxXOffset()
+ for i := 0; i < 50; i++ {
+ m = send(t, m, keys.right)
+ }
+ if m.detailXOffset != maxX || m.activeTab != tabProjects {
+ t.Fatalf("%s right: x=%d (max %d) tab=%d", keys.name, m.detailXOffset, maxX, m.activeTab)
+ }
+ for i := 0; i < 50; i++ {
+ m = send(t, m, keys.left)
+ }
+ if m.detailXOffset != 0 || m.activeTab != tabProjects || !m.projectReadmeScroll {
+ t.Fatalf("%s left: x=%d tab=%d focus=%v", keys.name, m.detailXOffset, m.activeTab, m.projectReadmeScroll)
+ }
+ }
+}
+
+func TestReadmeOffsetSurvivesAsyncRerender(t *testing.T) {
+ m := readmeModel(t)
+ for i := 0; i < 3; i++ {
+ m = send(t, m, tea.KeyMsg{Type: tea.KeyRight})
+ }
+ m = send(t, m, tea.KeyMsg{Type: tea.KeyDown})
+ x, y := m.detailXOffset, m.viewport.YOffset
+ if x == 0 {
+ t.Fatal("did not scroll")
+ }
+
+ id := m.projects[0].ID
+ w := m.projectMarkdownWidth()
+ rendered := renderProjectMarkdownCmd(id, replaceFirstH1WithSourceLink(wideReadme, "janitor", ""), "", w)().(projectMarkdownRenderedMsg)
+ m = send(t, m, rendered)
+ m = send(t, m, projectReadmeMsg{projectID: id, readme: wideReadme})
+ m = send(t, m, tea.WindowSizeMsg{Width: 100, Height: 40})
+ m = send(t, m, tickMsg(m.now))
+ if m.detailXOffset != x || m.viewport.YOffset != y {
+ t.Fatalf("re-render reset scroll: x=%d->%d y=%d->%d", x, m.detailXOffset, y, m.viewport.YOffset)
+ }
+
+ // changing the selected project resets it
+ m = send(t, m, tea.KeyMsg{Type: tea.KeyEsc})
+ m = send(t, m, tea.KeyMsg{Type: tea.KeyDown})
+ if m.detailXOffset != 0 || m.viewport.YOffset != 0 {
+ t.Fatalf("new project kept scroll: x=%d y=%d", m.detailXOffset, m.viewport.YOffset)
+ }
+}
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
@@ -13,6 +13,8 @@ type keyMap struct {
Quit key.Binding
PageDown key.Binding
PageUp key.Binding
+ Left key.Binding
+ Right key.Binding
}
func defaultKeyMap() keyMap {
@@ -57,6 +59,14 @@ func defaultKeyMap() keyMap {
key.WithKeys("pgup", "b", "shift+up"),
key.WithHelp("pgup", "scroll up"),
),
+ Left: key.NewBinding(
+ key.WithKeys("h", "shift+left"),
+ key.WithHelp("←/→ h/l", "scroll readme sideways"),
+ ),
+ Right: key.NewBinding(
+ key.WithKeys("l", "shift+right"),
+ key.WithHelp("←/→ h/l", "scroll readme sideways"),
+ ),
}
}
@@ -65,5 +75,5 @@ func (k keyMap) ShortHelp() []key.Binding {
}
func (k keyMap) FullHelp() [][]key.Binding {
- return [][]key.Binding{{k.TabPrev, k.TabNext, k.Up, k.Down, k.PageUp, k.PageDown}, {k.Open, k.Back, k.Help, k.Quit}}
+ return [][]key.Binding{{k.TabPrev, k.TabNext, k.Up, k.Down, k.PageUp, k.PageDown, k.Right}, {k.Open, k.Back, k.Help, k.Quit}}
}
diff --git a/internal/tui/layout.go b/internal/tui/layout.go
@@ -2,14 +2,54 @@ package tui
import "github.com/charmbracelet/lipgloss"
+// detailViewKey identifies what the detail pane is showing. Scroll offsets
+// survive re-renders of the same view (async readme/markdown results, window
+// resizes, ticks) and only reset when the view itself changes.
+type detailViewKey struct {
+ tab, project, experience int
+}
+
+func (m portfolioModel) currentDetailViewKey() detailViewKey {
+ return detailViewKey{tab: m.activeTab, project: m.projectIndex, experience: m.experienceIndex}
+}
+
+// syncViewport re-renders the detail pane, keeping the scroll position when
+// the same tab/item is still selected.
func (m *portfolioModel) syncViewport() {
- m.viewport.SetContent(m.detailContent())
+ key := m.currentDetailViewKey()
+ same := m.detailViewSet && key == m.detailView
+ m.detailView, m.detailViewSet = key, true
+
+ content := m.detailContent()
+ m.viewport.SetContent(content)
+ m.detailLineWidths = lineWidths(content)
w, h := m.detailSize()
m.viewport.Width = max(w, 1)
m.viewport.Height = max(h, 1)
+
+ if !same {
+ m.detailXOffset = 0
+ }
+ m.detailXOffset = min(max(m.detailXOffset, 0), m.detailMaxXOffset())
+ m.viewport.SetXOffset(m.detailXOffset)
+
+ if m.activeTab == tabAbout && (m.shell.cleared || len(m.shell.history) > 0 || m.shell.input != "") {
+ m.viewport.GotoBottom()
+ return
+ }
+ if same {
+ m.viewport.SetYOffset(m.viewport.YOffset) // clamp to the new content
+ return
+ }
m.viewport.GotoTop()
}
+// resetViewport re-renders the detail pane scrolled back to the top-left.
+func (m *portfolioModel) resetViewport() {
+ m.detailViewSet = false
+ m.syncViewport()
+}
+
func (m portfolioModel) detailSize() (int, int) {
bodyW, bodyH := m.bodySize()
diff --git a/internal/tui/markdown.go b/internal/tui/markdown.go
@@ -26,12 +26,13 @@ func renderMarkdown(markdown string, width int) string {
width = max(width, 20)
renderWidth := markdownRenderWidth(markdown, width)
- out := termmarkdown.Render(markdown, renderWidth, 0)
+ source, wide := extractWideBlocks(markdown, renderWidth)
+ out := termmarkdown.Render(source, renderWidth, 0)
if len(out) == 0 {
return markdown
}
- return styleRenderedMarkdown(strings.TrimRight(string(out), "\n"))
+ return restoreWideBlocks(styleRenderedMarkdown(strings.TrimRight(string(out), "\n")), wide)
}
func styleRenderedMarkdown(rendered string) string {
diff --git a/internal/tui/markdown_wide.go b/internal/tui/markdown_wide.go
@@ -0,0 +1,156 @@
+package tui
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/charmbracelet/x/ansi"
+)
+
+// Preformatted blocks (code, ascii diagrams) wider than the pane are pulled
+// out before markdown rendering and put back verbatim afterwards, so they
+// overflow (and scroll horizontally) instead of wrapping. Narrow blocks keep
+// the renderer's own styling and highlighting.
+
+const wideBlockToken = "PRIME8WIDEBLOCK"
+
+var (
+ fenceOpenRE = regexp.MustCompile("^ {0,3}(`{3,}|~{3,})")
+ listItemRE = regexp.MustCompile(`^\s*([-*+]|\d+[.)])\s`)
+ wideTokenRE = regexp.MustCompile(`^\s*` + wideBlockToken + `(\d+)\s*$`)
+ codeGutter = "┃ "
+ codeGutterLen = ansi.StringWidth(codeGutter)
+)
+
+// extractWideBlocks replaces every preformatted block whose longest line does
+// not fit in width with a placeholder paragraph.
+func extractWideBlocks(markdown string, width int) (string, [][]string) {
+ lines := strings.Split(markdown, "\n")
+ out := make([]string, 0, len(lines))
+ var blocks [][]string
+
+ emit := func(original []string, body []string) {
+ if blockWidth(body)+codeGutterLen <= width {
+ out = append(out, original...)
+ return
+ }
+ out = append(out, "", fmt.Sprintf("%s%d", wideBlockToken, len(blocks)), "")
+ blocks = append(blocks, body)
+ }
+
+ for i := 0; i < len(lines); i++ {
+ line := lines[i]
+
+ if m := fenceOpenRE.FindStringSubmatch(line); m != nil {
+ fence := m[1]
+ end := -1
+ for j := i + 1; j < len(lines); j++ {
+ t := strings.TrimSpace(lines[j])
+ if strings.HasPrefix(t, fence[:3]) && strings.Trim(t, fence[:1]) == "" && len(t) >= len(fence) {
+ end = j
+ break
+ }
+ }
+ if end < 0 {
+ out = append(out, lines[i:]...)
+ break
+ }
+ emit(lines[i:end+1], expandTabs(lines[i+1:end]))
+ i = end
+ continue
+ }
+
+ if isIndentedCode(line) && precededByBlankParagraph(lines, i) {
+ j := i
+ for j < len(lines) && (isIndentedCode(lines[j]) || strings.TrimSpace(lines[j]) == "") {
+ j++
+ }
+ for j > i && strings.TrimSpace(lines[j-1]) == "" {
+ j--
+ }
+ body := make([]string, 0, j-i)
+ for _, l := range lines[i:j] {
+ l = expandTabs([]string{l})[0]
+ if len(l) >= 4 {
+ l = l[4:]
+ } else {
+ l = ""
+ }
+ body = append(body, l)
+ }
+ emit(lines[i:j], body)
+ i = j - 1
+ continue
+ }
+
+ out = append(out, line)
+ }
+
+ return strings.Join(out, "\n"), blocks
+}
+
+func isIndentedCode(line string) bool {
+ return strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
+}
+
+// precededByBlankParagraph reports whether an indented line starts a code
+// block rather than continuing a list item.
+func precededByBlankParagraph(lines []string, i int) bool {
+ if i == 0 {
+ return true
+ }
+ if strings.TrimSpace(lines[i-1]) != "" {
+ return false
+ }
+ for j := i - 1; j >= 0; j-- {
+ if strings.TrimSpace(lines[j]) == "" {
+ continue
+ }
+ return !listItemRE.MatchString(lines[j]) && !isIndentedCode(lines[j])
+ }
+ return true
+}
+
+func expandTabs(lines []string) []string {
+ out := make([]string, len(lines))
+ for i, l := range lines {
+ out[i] = strings.ReplaceAll(l, "\t", " ")
+ }
+ return out
+}
+
+func blockWidth(lines []string) int {
+ w := 0
+ for _, l := range lines {
+ w = max(w, ansi.StringWidth(l))
+ }
+ return w
+}
+
+// restoreWideBlocks swaps the placeholders back for the verbatim block lines.
+func restoreWideBlocks(rendered string, blocks [][]string) string {
+ if len(blocks) == 0 {
+ return rendered
+ }
+ lines := strings.Split(rendered, "\n")
+ out := make([]string, 0, len(lines))
+ for _, line := range lines {
+ plain := ansi.Strip(line)
+ m := wideTokenRE.FindStringSubmatch(plain)
+ if m == nil {
+ out = append(out, line)
+ continue
+ }
+ var idx int
+ if _, err := fmt.Sscanf(m[1], "%d", &idx); err != nil || idx >= len(blocks) {
+ out = append(out, line)
+ continue
+ }
+ indent := plain[:len(plain)-len(strings.TrimLeft(plain, " "))]
+ for _, l := range blocks[idx] {
+ out = append(out, indent+dimStyle.Render(codeGutter)+rowStyle.Render(l))
+ }
+ }
+ return strings.Join(out, "\n")
+}
diff --git a/internal/tui/program.go b/internal/tui/program.go
@@ -1,8 +1,6 @@
package tui
import (
- "strings"
-
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
ssh "github.com/charmbracelet/ssh"
@@ -26,10 +24,8 @@ func (m portfolioModel) Init() tea.Cmd {
)
}
+// defaultUser is the SSH session user as shown in prompts, the window title,
+// whoami and the shell's home directory, sanitized to a safe path segment.
func defaultUser(user string) string {
- user = strings.TrimSpace(user)
- if user == "" {
- return "visitor"
- }
- return user
+ return sanitizeUser(user)
}
diff --git a/internal/tui/shell.go b/internal/tui/shell.go
@@ -0,0 +1,384 @@
+package tui
+
+import (
+ "sort"
+ "strings"
+ "unicode"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+const shellMaxHistory = 200
+
+var (
+ shellDirStyle = lipgloss.NewStyle().Foreground(colorBlue).Bold(true)
+ shellErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("167"))
+)
+
+type shellEntry struct {
+ cwd string
+ input string
+ output string
+ err bool
+}
+
+// shellState is the interactive prompt at the bottom of the about page.
+type shellState struct {
+ user string
+ home string
+ cwd string
+ input string
+ history []shellEntry
+ // cleared hides the about-page intro, like `clear` in a real terminal.
+ cleared bool
+}
+
+func newShellState(user string) shellState {
+ user = sanitizeUser(user)
+ home := homeDir(user)
+ return shellState{user: user, home: home, cwd: home}
+}
+
+type shellResult struct {
+ output string
+ err bool
+ clear bool
+ quit bool
+}
+
+func (s *shellState) exec(root *vfsNode, line string) shellResult {
+ fields := strings.Fields(line)
+ if len(fields) == 0 {
+ return shellResult{}
+ }
+ cmd, args := fields[0], fields[1:]
+ errf := func(msg string) shellResult { return shellResult{output: msg, err: true} }
+
+ switch cmd {
+ case "exit", "logout":
+ return shellResult{quit: true}
+ case "clear":
+ return shellResult{clear: true}
+ case "help":
+ return shellResult{output: shellHelpText}
+ case "pwd":
+ return shellResult{output: s.cwd}
+ case "whoami":
+ return shellResult{output: s.user}
+ case "echo":
+ return shellResult{output: strings.Join(args, " ")}
+ case "cd":
+ target := ""
+ if len(args) > 0 {
+ target = args[0]
+ }
+ if len(args) > 1 {
+ return errf("cd: too many arguments")
+ }
+ abs := resolvePath(s.home, s.cwd, target)
+ n := root.lookup(abs)
+ switch {
+ case n == nil:
+ return errf("cd: " + target + ": No such file or directory")
+ case !n.dir:
+ return errf("cd: " + target + ": Not a directory")
+ }
+ s.cwd = abs
+ return shellResult{}
+ case "ls":
+ paths := make([]string, 0, len(args))
+ for _, a := range args {
+ if !strings.HasPrefix(a, "-") {
+ paths = append(paths, a)
+ }
+ }
+ if len(paths) == 0 {
+ paths = []string{"."}
+ }
+ var out []string
+ failed := false
+ for _, p := range paths {
+ n := root.lookup(resolvePath(s.home, s.cwd, p))
+ if n == nil {
+ out = append(out, shellErrorStyle.Render("ls: cannot access '"+p+"': No such file or directory"))
+ failed = true
+ continue
+ }
+ listing := renderLs(n)
+ if len(paths) > 1 && n.dir {
+ listing = p + ":\n" + listing
+ }
+ out = append(out, listing)
+ }
+ return shellResult{output: strings.Join(out, "\n"), err: failed && len(paths) == 1}
+ case "cat":
+ if len(args) == 0 {
+ return errf("cat: missing operand")
+ }
+ var out []string
+ failed := false
+ for _, p := range args {
+ n := root.lookup(resolvePath(s.home, s.cwd, p))
+ switch {
+ case n == nil:
+ out = append(out, shellErrorStyle.Render("cat: "+p+": No such file or directory"))
+ failed = true
+ case n.dir:
+ out = append(out, shellErrorStyle.Render("cat: "+p+": Is a directory"))
+ failed = true
+ default:
+ out = append(out, rowStyle.Render(n.content))
+ }
+ }
+ return shellResult{output: strings.Join(out, "\n"), err: failed && len(args) == 1}
+ case "sudo":
+ return errf(s.user + " is not in the sudoers file. This incident will be reported.")
+ default:
+ return errf(cmd + ": command not found")
+ }
+}
+
+const shellHelpText = `commands:
+ ls [path] list a directory
+ cd [path] change directory (~ is home)
+ pwd print working directory
+ cat <file> print a file
+ clear clear the screen
+ help show this
+ exit close the session
+
+try: cat about, ls projects, cat projects/janitor/README.md`
+
+func renderLs(n *vfsNode) string {
+ if !n.dir {
+ return rowStyle.Render(n.name)
+ }
+ children := n.sortedChildren()
+ if len(children) == 0 {
+ return ""
+ }
+ parts := make([]string, 0, len(children))
+ for _, c := range children {
+ if c.dir {
+ parts = append(parts, shellDirStyle.Render(c.name+"/"))
+ } else {
+ parts = append(parts, rowStyle.Render(c.name))
+ }
+ }
+ return strings.Join(parts, " ")
+}
+
+// complete expands the last word of the input against the vfs.
+func (s *shellState) complete(root *vfsNode) {
+ if s.input == "" {
+ return
+ }
+ idx := strings.LastIndexFunc(s.input, unicode.IsSpace)
+ if idx < 0 {
+ // first word: complete command names
+ matches := []string{}
+ for _, c := range shellCommands {
+ if strings.HasPrefix(c, s.input) {
+ matches = append(matches, c)
+ }
+ }
+ if len(matches) == 1 {
+ s.input = matches[0] + " "
+ } else if p := commonPrefix(matches); len(p) > len(s.input) {
+ s.input = p
+ }
+ return
+ }
+
+ word := s.input[idx+1:]
+ dirPart, base := "", word
+ if i := strings.LastIndex(word, "/"); i >= 0 {
+ dirPart, base = word[:i+1], word[i+1:]
+ }
+ dir := root.lookup(resolvePath(s.home, s.cwd, dirPart))
+ if dir == nil || !dir.dir {
+ return
+ }
+ var matches []string
+ nodes := map[string]*vfsNode{}
+ for _, c := range dir.sortedChildren() {
+ if strings.HasPrefix(c.name, base) {
+ matches = append(matches, c.name)
+ nodes[c.name] = c
+ }
+ }
+ if len(matches) == 0 {
+ return
+ }
+ completed := commonPrefix(matches)
+ if len(matches) == 1 {
+ if nodes[completed].dir {
+ completed += "/"
+ } else {
+ completed += " "
+ }
+ }
+ s.input = s.input[:idx+1] + dirPart + completed
+}
+
+var shellCommands = []string{"cat", "cd", "clear", "echo", "exit", "help", "ls", "pwd", "whoami"}
+
+func commonPrefix(words []string) string {
+ if len(words) == 0 {
+ return ""
+ }
+ sorted := append([]string(nil), words...)
+ sort.Strings(sorted)
+ first, last := sorted[0], sorted[len(sorted)-1]
+ i := 0
+ for i < len(first) && i < len(last) && first[i] == last[i] {
+ i++
+ }
+ return first[:i]
+}
+
+// handleShellKey routes a key on the about page to the prompt. It reports
+// whether the key was consumed; unconsumed keys fall through to the global
+// bindings (tab switching, scrolling, quit, ...).
+func (m *portfolioModel) handleShellKey(msg tea.KeyMsg) (tea.Cmd, bool) {
+ s := &m.shell
+ switch msg.Type {
+ case tea.KeyRunes, tea.KeySpace:
+ r := string(msg.Runes)
+ if msg.Type == tea.KeySpace {
+ r = " "
+ }
+ // with an empty prompt, keep the digit tab shortcuts and ? help
+ if s.input == "" && !msg.Paste {
+ if _, ok := tabIndexFromKey(r); ok {
+ return nil, false
+ }
+ if r == "?" || r == " " {
+ return nil, false
+ }
+ }
+ s.input += strings.Map(func(r rune) rune {
+ if unicode.IsControl(r) {
+ return -1
+ }
+ return r
+ }, r)
+ case tea.KeyBackspace, tea.KeyCtrlH:
+ if s.input == "" {
+ return nil, true
+ }
+ rs := []rune(s.input)
+ s.input = string(rs[:len(rs)-1])
+ case tea.KeyCtrlU:
+ s.input = ""
+ case tea.KeyCtrlW:
+ trimmed := strings.TrimRightFunc(s.input, unicode.IsSpace)
+ if i := strings.LastIndexFunc(trimmed, unicode.IsSpace); i >= 0 {
+ s.input = trimmed[:i+1]
+ } else {
+ s.input = ""
+ }
+ case tea.KeyCtrlL:
+ s.history = nil
+ s.cleared = true
+ case tea.KeyCtrlD:
+ if s.input == "" {
+ return tea.Quit, true
+ }
+ return nil, true
+ case tea.KeyEsc:
+ if s.input == "" {
+ return nil, false
+ }
+ s.input = ""
+ case tea.KeyTab:
+ if s.input == "" {
+ return nil, false
+ }
+ s.complete(buildVFS(m.vfsSource()))
+ case tea.KeyEnter:
+ return m.runShellLine(), true
+ default:
+ return nil, false
+ }
+ m.syncShellViewport()
+ return nil, true
+}
+
+func (m *portfolioModel) runShellLine() tea.Cmd {
+ s := &m.shell
+ line := s.input
+ s.input = ""
+ cwd := s.cwd
+ res := s.exec(buildVFS(m.vfsSource()), line)
+ if res.quit {
+ return tea.Quit
+ }
+ if res.clear {
+ s.history = nil
+ s.cleared = true
+ } else {
+ s.history = append(s.history, shellEntry{cwd: cwd, input: line, output: res.output, err: res.err})
+ if len(s.history) > shellMaxHistory {
+ s.history = s.history[len(s.history)-shellMaxHistory:]
+ }
+ }
+ m.syncShellViewport()
+ return m.prefetchAllProjectReadmesCmd()
+}
+
+// prefetchAllProjectReadmesCmd fills the projects/*/README.md files.
+func (m *portfolioModel) prefetchAllProjectReadmesCmd() tea.Cmd {
+ cmds := []tea.Cmd{}
+ for _, p := range m.projects {
+ if _, ok := m.projectReadmes[p.ID]; ok || m.projectReadmeLoads[p.ID] || m.gitService == nil {
+ continue
+ }
+ m.projectReadmeLoads[p.ID] = true
+ cmds = append(cmds, fetchProjectReadmeCmd(m.gitService, p))
+ }
+ if len(cmds) == 0 {
+ return nil
+ }
+ return tea.Batch(cmds...)
+}
+
+// syncShellViewport re-renders the about page and keeps the prompt in view.
+func (m *portfolioModel) syncShellViewport() {
+ if m.activeTab != tabAbout {
+ return
+ }
+ m.viewport.SetContent(m.detailContent())
+ m.viewport.GotoBottom()
+}
+
+// shellView renders past commands, their output, and the live prompt line.
+func (m portfolioModel) shellView(width int) string {
+ wrap := lipgloss.NewStyle().Width(max(width, 10))
+ parts := make([]string, 0, len(m.shell.history)*2+1)
+ for _, e := range m.shell.history {
+ parts = append(parts, renderShellPrompt(m.shell.user, m.shell.home, e.cwd, e.input))
+ if e.output == "" {
+ continue
+ }
+ out := e.output
+ if e.err {
+ out = shellErrorStyle.Render(out)
+ }
+ parts = append(parts, wrap.Render(out))
+ }
+ parts = append(parts, renderShellPrompt(m.shell.user, m.shell.home, m.shell.cwd, m.shell.input)+rowStyle.Render("█"))
+ return strings.Join(parts, "\n")
+}
+
+func renderShellPrompt(user, home, cwd, input string) string {
+ return promptUserStyle.Render(user) +
+ promptStyle.Render("@") +
+ promptHostStyle.Render("prime8") +
+ promptStyle.Render(":") +
+ promptPathStyle.Render(displayPath(home, cwd)) +
+ promptDollarStyle.Render("$") +
+ promptStyle.Render(" ") +
+ promptCmdStyle.Render(input)
+}
diff --git a/internal/tui/shell_test.go b/internal/tui/shell_test.go
@@ -0,0 +1,278 @@
+package tui
+
+import (
+ "math/rand"
+ "strings"
+ "testing"
+ "time"
+
+ appconfig "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ "github.com/charmbracelet/bubbles/help"
+ "github.com/charmbracelet/bubbles/viewport"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/muesli/termenv"
+)
+
+// testModel builds a model without network or git access.
+func testModel(t *testing.T, w, h int) portfolioModel {
+ t.Helper()
+ lipgloss.SetColorProfile(termenv.ANSI256)
+ exp := appconfig.GetExperience()
+ projects := appconfig.GetProjects()
+ m := portfolioModel{
+ user: "visitor",
+ about: appconfig.GetAbout(),
+ currentJob: appconfig.CurrentJob(exp),
+ aboutASCIICache: map[int]string{},
+ aboutASCIILoads: map[int]bool{},
+ projects: projects,
+ experience: exp,
+ now: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC),
+ width: w,
+ height: h,
+ bootRand: rand.New(rand.NewSource(1)),
+ keys: defaultKeyMap(),
+ help: help.New(),
+ viewport: viewport.New(1, 1),
+ shell: newShellState("visitor"),
+ projectReadmes: map[string]string{projects[0].ID: "# janitor\n\nsorts audio."},
+ projectReadmeErrors: map[string]string{},
+ projectReadmeLoads: map[string]bool{},
+ projectReadmeRender: map[string]map[int]string{},
+ projectMarkdownLoads: map[string]bool{},
+ }
+ m.syncViewport()
+ return m
+}
+
+func typeString(m *portfolioModel, s string) tea.Cmd {
+ var cmd tea.Cmd
+ for _, r := range s {
+ if r == '\n' {
+ cmd = m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyEnter})
+ continue
+ }
+ if r == ' ' {
+ cmd = m.handleKeyMsg(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}})
+ continue
+ }
+ cmd = m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+ }
+ return cmd
+}
+
+func isQuit(cmd tea.Cmd) bool {
+ if cmd == nil {
+ return false
+ }
+ _, ok := cmd().(tea.QuitMsg)
+ return ok
+}
+
+func plainView(m portfolioModel) string { return ansi.Strip(m.View()) }
+
+func TestShellPromptRendersOnAboutPage(t *testing.T) {
+ m := testModel(t, 120, 60)
+ if !strings.Contains(plainView(m), "visitor@prime8:~$ █") {
+ t.Fatalf("prompt not rendered:\n%s", plainView(m))
+ }
+}
+
+func TestShellTypingDoesNotTriggerShortcuts(t *testing.T) {
+ m := testModel(t, 120, 60)
+ // q, f, b, h are bindings elsewhere; on the about page they are text.
+ if cmd := typeString(&m, "qfbh"); isQuit(cmd) {
+ t.Fatal("q quit the session while typing")
+ }
+ if m.shell.input != "qfbh" {
+ t.Fatalf("input = %q", m.shell.input)
+ }
+ if !strings.Contains(plainView(m), "visitor@prime8:~$ qfbh█") {
+ t.Fatalf("typed text not shown:\n%s", plainView(m))
+ }
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyBackspace})
+ if m.shell.input != "qfb" {
+ t.Fatalf("backspace: input = %q", m.shell.input)
+ }
+}
+
+func TestShellTabSwitchingStillWorks(t *testing.T) {
+ m := testModel(t, 120, 60)
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}})
+ if m.activeTab != tabProjects {
+ t.Fatalf("digit shortcut with empty prompt: tab = %d", m.activeTab)
+ }
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}})
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyTab})
+ if m.activeTab != tabExperience {
+ t.Fatalf("tab key with empty prompt: tab = %d", m.activeTab)
+ }
+}
+
+func TestShellExitQuits(t *testing.T) {
+ m := testModel(t, 120, 60)
+ if cmd := typeString(&m, "exit\n"); !isQuit(cmd) {
+ t.Fatal("exit did not quit")
+ }
+}
+
+func TestShellCommands(t *testing.T) {
+ m := testModel(t, 120, 80)
+ typeString(&m, "ls\n")
+ typeString(&m, "cd projects\n")
+ typeString(&m, "pwd\n")
+ typeString(&m, "ls\n")
+ typeString(&m, "cat janitor/README.md\n")
+ typeString(&m, "cat nope\n")
+ typeString(&m, "cd ../bio\n")
+ typeString(&m, "rm -rf /\n")
+ typeString(&m, "cat ../../../../etc/passwd\n")
+ out := plainView(m)
+ for _, want := range []string{
+ "about bio contact experience/ hobbies/ interests/ languages/ projects/",
+ "visitor@prime8:~/projects$ pwd",
+ "/home/visitor/projects",
+ "computer-science-club/ finger/ janitor/ summarizer/",
+ "sorts audio.",
+ "cat: nope: No such file or directory",
+ "cd: ../bio: Not a directory",
+ "rm: command not found",
+ "cat: ../../../../etc/passwd: No such file or directory",
+ "visitor@prime8:~/projects$ █",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("missing %q in:\n%s", want, out)
+ }
+ }
+}
+
+func TestShellClear(t *testing.T) {
+ m := testModel(t, 120, 60)
+ typeString(&m, "ls\nclear\n")
+ out := plainView(m)
+ if strings.Contains(out, "cat bio") || strings.Contains(out, "experience/") {
+ t.Fatalf("clear left output behind:\n%s", out)
+ }
+ if !strings.Contains(out, "visitor@prime8:~$ █") {
+ t.Fatalf("prompt missing after clear:\n%s", out)
+ }
+}
+
+func TestShellCompletion(t *testing.T) {
+ m := testModel(t, 120, 60)
+ typeString(&m, "cat proj")
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyTab})
+ if m.shell.input != "cat projects/" {
+ t.Fatalf("completion: %q", m.shell.input)
+ }
+ typeString(&m, "ja")
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyTab})
+ if m.shell.input != "cat projects/janitor/" {
+ t.Fatalf("completion: %q", m.shell.input)
+ }
+}
+
+func TestVFSNeverEscapesRoot(t *testing.T) {
+ m := testModel(t, 120, 60)
+ root := buildVFS(m.vfsSource())
+ home := m.shell.home
+ for _, p := range []string{"/etc/passwd", "/proc/self/environ", "../../..", "/srv"} {
+ n := root.lookup(resolvePath(home, home, p))
+ if n != nil && n != root {
+ t.Errorf("%s resolved to %q", p, n.name)
+ }
+ }
+ if n := root.lookup(resolvePath(home, home, "../../..")); n != root {
+ t.Error(".. above / should stay at /")
+ }
+}
+
+func TestSanitizeUser(t *testing.T) {
+ for in, want := range map[string]string{
+ "": "visitor",
+ " ": "visitor",
+ "Damian": "damian",
+ "moncheeta": "moncheeta",
+ "../../etc": "etc",
+ "..": "visitor",
+ "a/b c": "abc",
+ "j.doe_1-x": "j.doe_1-x",
+ "\x1b[31mred": "31mred",
+ "élan": "lan",
+ strings.Repeat("a", 50): strings.Repeat("a", 32),
+ } {
+ if got := sanitizeUser(in); got != want {
+ t.Errorf("sanitizeUser(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestShellUsesSessionUser(t *testing.T) {
+ m := testModel(t, 120, 80)
+ m.user = defaultUser("Dam/ian")
+ m.shell = newShellState(m.user)
+ m.syncViewport()
+ typeString(&m, "whoami\n")
+ typeString(&m, "pwd\n")
+ typeString(&m, "sudo ls\n")
+ typeString(&m, "cd ~/projects\n")
+ out := plainView(m)
+ for _, want := range []string{
+ "damian@prime8:~$ whoami",
+ "/home/damian",
+ "damian is not in the sudoers file.",
+ "damian@prime8:~/projects$ █",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("missing %q in:\n%s", want, out)
+ }
+ }
+ if strings.Contains(out, "guest") {
+ t.Errorf("guest leaked:\n%s", out)
+ }
+ if got := m.windowTitle(); got != "damian@prime8:~" {
+ t.Errorf("window title = %q", got)
+ }
+ m.shell = newShellState(m.user)
+ m.syncViewport()
+ if out := plainView(m); !strings.Contains(out, "damian@prime8:~$ cat bio") {
+ t.Errorf("intro prompt not using session user:\n%s", out)
+ }
+}
+
+func TestProjectsSharedBorderIsAccent(t *testing.T) {
+ accent := "\x1b[38;5;179m"
+ for _, w := range []int{120, 90} {
+ m := testModel(t, w, 30)
+ m.activateTab(tabProjects)
+ for _, readme := range []bool{false, true} {
+ m.projectReadmeScroll = readme
+ view := m.View()
+ lines := strings.Split(view, "\n")
+ // every line holding a divider glyph must draw it in the accent color
+ found := false
+ for _, line := range lines {
+ for _, glyph := range []string{"┬", "┴", "├", "┤"} {
+ i := strings.Index(line, glyph)
+ if i < 0 {
+ continue
+ }
+ found = true
+ if j := strings.LastIndex(line[:i], "\x1b["); j < 0 || !strings.HasPrefix(line[j:], accent) {
+ t.Errorf("w=%d readme=%v: %s not accent-colored", w, readme, glyph)
+ }
+ }
+ }
+ if !found {
+ t.Errorf("w=%d readme=%v: no shared border junction found", w, readme)
+ }
+ for _, l := range strings.Split(ansi.Strip(view), "\n") {
+ if lipgloss.Width(l) > w {
+ t.Errorf("w=%d readme=%v: line overflows (%d)", w, readme, lipgloss.Width(l))
+ }
+ }
+ }
+ }
+}
diff --git a/internal/tui/types.go b/internal/tui/types.go
@@ -41,6 +41,15 @@ type portfolioModel struct {
projectReadmeScroll bool
experienceIndex int
+ shell shellState
+
+ // detailLineWidths holds the visible width of each viewport line, for
+ // horizontal scrolling and the cut-off markers.
+ detailLineWidths []int
+ detailXOffset int
+ detailView detailViewKey
+ detailViewSet bool
+
width int
height int
now time.Time
@@ -109,6 +118,7 @@ func newPortfolioModel(user string) portfolioModel {
keys: defaultKeyMap(),
help: h,
viewport: vp,
+ shell: newShellState(user),
projectReadmes: make(map[string]string),
projectReadmeErrors: make(map[string]string),
diff --git a/internal/tui/update.go b/internal/tui/update.go
@@ -38,7 +38,7 @@ func (m portfolioModel) windowTitle() string {
path += "/" + p.ID
}
}
- return m.about.Handle + "@prime8:" + path
+ return m.user + "@prime8:" + path
}
func (m portfolioModel) update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -144,6 +144,28 @@ func (m *portfolioModel) handleKeyMsg(msg tea.KeyMsg) tea.Cmd {
}
}
+ if m.activeTab == tabAbout {
+ if cmd, ok := m.handleShellKey(msg); ok {
+ return cmd
+ }
+ }
+
+ if m.activeTab == tabProjects {
+ // With the readme focused, plain arrows pan sideways instead of
+ // switching tabs; left at the left edge falls through and backs out.
+ readme := m.projectReadmeScroll
+ switch {
+ case key.Matches(msg, m.keys.Left),
+ readme && msg.Type == tea.KeyLeft && m.detailXOffset > 0:
+ m.scrollDetailX(-detailHorizontalStep)
+ return nil
+ case key.Matches(msg, m.keys.Right),
+ readme && msg.Type == tea.KeyRight:
+ m.scrollDetailX(detailHorizontalStep)
+ return nil
+ }
+ }
+
if m.consumeProjectReadmeBack(msg) {
return nil
}
@@ -192,7 +214,7 @@ func (m *portfolioModel) activateTab(tab int) tea.Cmd {
m.activeTab = tab
m.projectReadmeScroll = false
- m.syncViewport()
+ m.resetViewport()
return m.refreshDataCmd()
}
@@ -207,7 +229,7 @@ func (m *portfolioModel) consumeProjectReadmeBack(msg tea.KeyMsg) bool {
}
m.projectReadmeScroll = false
- m.syncViewport()
+ m.resetViewport()
return true
}
@@ -227,7 +249,7 @@ func (m *portfolioModel) handleOpenKey() tea.Cmd {
func (m *portfolioModel) handleBackKey() tea.Cmd {
if m.activeTab == tabProjects && m.projectReadmeScroll {
m.projectReadmeScroll = false
- m.syncViewport()
+ m.resetViewport()
return nil
}
diff --git a/internal/tui/vfs.go b/internal/tui/vfs.go
@@ -0,0 +1,267 @@
+package tui
+
+import (
+ "fmt"
+ "path"
+ "sort"
+ "strings"
+ "time"
+
+ appconfig "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ "git.prime8.dev/moncheeta/prime8dev/internal/utils"
+)
+
+// The shell on the about page runs against a read-only, in-memory filesystem
+// built from the site's own content. Nothing here ever touches the host
+// filesystem: every node is generated from config/content values.
+
+const (
+ fallbackUser = "visitor"
+ maxShellUser = 32
+ homeDirPrefix = "/home/"
+)
+
+// sanitizeUser turns the SSH session user into a safe single path segment
+// ([a-z0-9._-], at most 32 chars) so it can name the home directory and the
+// prompt. Anything that sanitizes to nothing (or to . / ..) becomes visitor.
+func sanitizeUser(user string) string {
+ var b strings.Builder
+ for _, r := range strings.ToLower(strings.TrimSpace(user)) {
+ if b.Len() >= maxShellUser {
+ break
+ }
+ switch {
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
+ b.WriteRune(r)
+ }
+ }
+ s := strings.Trim(b.String(), ".")
+ if s == "" {
+ return fallbackUser
+ }
+ return s
+}
+
+func homeDir(user string) string {
+ return homeDirPrefix + user
+}
+
+type vfsNode struct {
+ name string
+ dir bool
+ content string
+ children map[string]*vfsNode
+}
+
+func newVFSDir(name string) *vfsNode {
+ return &vfsNode{name: name, dir: true, children: map[string]*vfsNode{}}
+}
+
+func (n *vfsNode) addDir(name string) *vfsNode {
+ if c, ok := n.children[name]; ok && c.dir {
+ return c
+ }
+ c := newVFSDir(name)
+ n.children[name] = c
+ return c
+}
+
+func (n *vfsNode) addFile(name, content string) {
+ n.children[name] = &vfsNode{name: name, content: content}
+}
+
+func (n *vfsNode) sortedChildren() []*vfsNode {
+ out := make([]*vfsNode, 0, len(n.children))
+ for _, c := range n.children {
+ out = append(out, c)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
+ return out
+}
+
+// vfsSource is everything the filesystem is generated from.
+type vfsSource struct {
+ user string
+ about appconfig.About
+ currentJob *appconfig.Experience
+ experience []appconfig.Experience
+ projects []appconfig.Project
+ readmes map[string]string
+ now time.Time
+}
+
+func (m portfolioModel) vfsSource() vfsSource {
+ return vfsSource{
+ user: m.shell.user,
+ about: m.about,
+ currentJob: m.currentJob,
+ experience: m.experience,
+ projects: m.projects,
+ readmes: m.projectReadmes,
+ now: m.now,
+ }
+}
+
+// buildVFS lays out:
+//
+// /home/<user>/{about,bio,contact}
+// /home/<user>/{interests,hobbies,languages}/<item>
+// /home/<user>/experience/<id>
+// /home/<user>/projects/<id>/{README.md,info}
+func buildVFS(src vfsSource) *vfsNode {
+ root := newVFSDir("/")
+ home := root.addDir("home").addDir(src.user)
+ root.addDir("tmp")
+
+ a := src.about
+ home.addFile("bio", a.Bio)
+ home.addFile("about", vfsAboutFile(src))
+ home.addFile("contact", strings.Join([]string{
+ "email: damian@prime8.dev",
+ "web: " + appconfig.SiteURL,
+ "git: " + appconfig.SiteURL + "/projects",
+ "linkedin: https://linkedin.com/in/damian-myrda",
+ "ssh: ssh prime8.dev",
+ }, "\n"))
+
+ for dir, items := range map[string][]string{
+ "interests": a.Interests,
+ "hobbies": a.Hobbies,
+ "languages": a.Languages,
+ } {
+ d := home.addDir(dir)
+ for _, item := range items {
+ if name := vfsName(item); name != "" {
+ d.addFile(name, item)
+ }
+ }
+ }
+
+ expDir := home.addDir("experience")
+ for _, e := range src.experience {
+ if name := vfsName(e.ID); name != "" {
+ expDir.addFile(name, vfsExperienceFile(e))
+ }
+ }
+
+ projDir := home.addDir("projects")
+ for _, p := range src.projects {
+ name := vfsName(p.ID)
+ if name == "" {
+ continue
+ }
+ d := projDir.addDir(name)
+ readme := strings.TrimSpace(src.readmes[p.ID])
+ if readme == "" {
+ readme = strings.TrimSpace(p.Markdown)
+ }
+ if readme != "" {
+ d.addFile("README.md", readme)
+ }
+ info := []string{"name: " + p.Name, "about: " + p.Subtitle}
+ if len(p.Tech) > 0 {
+ info = append(info, "tech: "+strings.Join(p.Tech, ", "))
+ }
+ if p.Repo != "" {
+ info = append(info,
+ "clone: git clone "+appconfig.GitCloneURL(p.Repo),
+ " git clone "+appconfig.GitSSHCloneURL(p.Repo),
+ )
+ }
+ d.addFile("info", strings.Join(info, "\n"))
+ }
+
+ return root
+}
+
+func vfsAboutFile(src vfsSource) string {
+ a := src.about
+ role := a.Profession
+ if src.currentJob != nil {
+ role = src.currentJob.Role + " @ " + src.currentJob.Company
+ }
+ now := src.now
+ if now.IsZero() {
+ now = time.Now()
+ }
+ lines := []string{
+ "name: " + a.Name,
+ "role: " + role,
+ "location: " + a.Location,
+ "uptime: " + utils.Uptime(appconfig.Birthday, now),
+ }
+ for _, kv := range a.Setup {
+ lines = append(lines, fmt.Sprintf("%-9s %s", kv.Key+":", kv.Value))
+ }
+ lines = append(lines, "langs: "+strings.Join(a.Languages, ", "))
+ return strings.Join(lines, "\n")
+}
+
+func vfsExperienceFile(e appconfig.Experience) string {
+ lines := []string{
+ e.Role + " @ " + e.Company,
+ dateRangeLabel(e),
+ "",
+ }
+ lines = append(lines, e.Description...)
+ if len(e.Tech) > 0 {
+ lines = append(lines, "", "tech: "+strings.Join(e.Tech, ", "))
+ }
+ if e.URL != "" {
+ lines = append(lines, "url: "+e.URL)
+ }
+ return strings.Join(lines, "\n")
+}
+
+// vfsName turns a label into a single path component.
+func vfsName(s string) string {
+ s = strings.TrimSpace(strings.ToLower(s))
+ s = strings.NewReplacer("/", "-", " ", "-").Replace(s)
+ if s == "." || s == ".." {
+ return ""
+ }
+ return s
+}
+
+// resolvePath turns p (absolute, ~-relative, or relative to cwd) into a clean
+// absolute virtual path.
+func resolvePath(home, cwd, p string) string {
+ switch {
+ case p == "" || p == "~":
+ return home
+ case strings.HasPrefix(p, "~/"):
+ p = home + p[1:]
+ case !strings.HasPrefix(p, "/"):
+ p = cwd + "/" + p
+ }
+ return path.Clean(p)
+}
+
+func (root *vfsNode) lookup(abs string) *vfsNode {
+ n := root
+ for _, part := range strings.Split(strings.Trim(abs, "/"), "/") {
+ if part == "" {
+ continue
+ }
+ if !n.dir {
+ return nil
+ }
+ c, ok := n.children[part]
+ if !ok {
+ return nil
+ }
+ n = c
+ }
+ return n
+}
+
+// displayPath abbreviates the home directory to ~.
+func displayPath(home, abs string) string {
+ if abs == home {
+ return "~"
+ }
+ if strings.HasPrefix(abs, home+"/") {
+ return "~" + abs[len(home):]
+ }
+ return abs
+}
diff --git a/internal/tui/view.go b/internal/tui/view.go
@@ -61,13 +61,16 @@ func (m portfolioModel) renderHelpBody(width int, height int) string {
key string
desc string
}{
- {key: "◀ ▶", desc: "navigate tabs"},
+ {key: "◀ ▶", desc: "navigate tabs (pan sideways in a readme)"},
{key: "▲ ▼", desc: "switch list items"},
{key: "shift + ▲ ▼", desc: "scroll item details"},
+ {key: "◀ ▶ h l", desc: "scroll wide code sideways in a readme"},
+ {key: "esc", desc: "leave the readme (or ◀ at the left edge)"},
{key: "tab", desc: "cycle tabs"},
{key: "1-3", desc: "switch tabs"},
{key: "enter", desc: "open item"},
{key: "?", desc: "toggle help"},
+ {key: "type", desc: "shell on the about page (try help, exit)"},
}
panelW := min(max(width-8, 42), 70)
@@ -179,17 +182,18 @@ func (m portfolioModel) renderBody(width int, height int) string {
func (m portfolioModel) renderProjectsBody(bodyW int, height int) string {
listFocused := !m.projectReadmeScroll
listContent := func(w, h int) string { return m.projectListContent(w, h) }
+ detail := m.detailViewWithCutMarkers()
if bodyW < 72 {
stackTop := max(height/2, 3)
stackBottom := max(height-stackTop, 1)
if listFocused {
listPane := renderPanelWithStyle(panelStyleSharedBottom(panelActiveStyle), bodyW, stackTop+1, listContent(bodyW-4, stackTop-2))
- detailPane := renderPanelWithStyle(panelStyleWithoutTop(panelStyle), bodyW, max(stackBottom-1, 1), m.viewport.View())
+ detailPane := renderPanelWithStyle(panelStyleWithoutTop(panelStyle), bodyW, max(stackBottom-1, 1), detail)
return lipgloss.JoinVertical(lipgloss.Left, listPane, detailPane)
}
listPane := renderPanelWithStyle(panelStyleWithoutBottom(panelStyle), bodyW, stackTop, listContent(bodyW-4, stackTop-2))
- detailPane := renderPanelWithStyle(panelStyleSharedTop(panelActiveStyle), bodyW, stackBottom, m.viewport.View())
+ detailPane := renderPanelWithStyle(panelStyleSharedTop(panelActiveStyle), bodyW, stackBottom, detail)
return lipgloss.JoinVertical(lipgloss.Left, listPane, detailPane)
}
@@ -197,11 +201,11 @@ func (m portfolioModel) renderProjectsBody(bodyW int, height int) string {
detailW := max(bodyW-listW, 12)
if listFocused {
listPane := renderPanelWithStyle(panelStyleSharedRight(panelActiveStyle), listW+1, height, listContent(listW-4, height-2))
- detailPane := renderPanelWithStyle(panelStyleWithoutLeft(panelStyle), max(detailW-1, 1), height, m.viewport.View())
+ detailPane := renderPanelWithStyle(panelStyleWithoutLeft(panelStyle), max(detailW-1, 1), height, detail)
return lipgloss.JoinHorizontal(lipgloss.Top, listPane, detailPane)
}
listPane := renderPanelWithStyle(panelStyleWithoutRight(panelStyle), listW, height, listContent(listW-4, height-2))
- detailPane := renderPanelWithStyle(panelStyleSharedLeft(panelActiveStyle), detailW, height, m.viewport.View())
+ detailPane := renderPanelWithStyle(panelStyleSharedLeft(panelActiveStyle), detailW, height, detail)
return lipgloss.JoinHorizontal(lipgloss.Top, listPane, detailPane)
}
diff --git a/internal/tui/waka_footer.go b/internal/tui/waka_footer.go
@@ -31,7 +31,11 @@ func (m *portfolioModel) maybeLoadWakaFooterCmd() tea.Cmd {
func fetchWakaFooterCmd(service *external.WakaTime) tea.Cmd {
return func() tea.Msg {
- stats, err := service.FetchStats()
+ fetch := service.FetchTotal
+ if config.WakaTimeWeeklyEnabled {
+ fetch = service.FetchStats
+ }
+ stats, err := fetch()
if err != nil || stats == nil {
return wakaFooterMsg{ok: false}
}
diff --git a/internal/web/renderer.go b/internal/web/renderer.go
@@ -54,23 +54,24 @@ func New() (*Renderer, error) {
}
funcs := template.FuncMap{
- "tabPath": utils.TabPath,
- "tabURL": utils.TabURL,
- "tagClass": utils.TagClass,
- "tagColor": func(label string) template.CSS { return template.CSS(utils.TagColor(label)) },
- "shellPath": ShellPath,
- "tagCSS": func() template.CSS { return template.CSS(utils.TagCSS()) },
- "inc": func(i int) int { return i + 1 },
- "techMeta": utils.TechMeta,
- "formatMonthYear": utils.FormatMonthYear,
- "formatEndDate": utils.FormatEndDate,
- "formatDuration": utils.FormatDuration,
- "durationHasYear": utils.DurationHasYear,
- "timelineYear": utils.TimelineYear,
- "toHTML": toHTML,
- "gitCloneURL": config.GitCloneURL,
- "gitSSHCloneURL": config.GitSSHCloneURL,
- "wakaTimeEnabled": func() bool { return config.WakaTimeEnabled },
+ "tabPath": utils.TabPath,
+ "tabURL": utils.TabURL,
+ "tagClass": utils.TagClass,
+ "tagColor": func(label string) template.CSS { return template.CSS(utils.TagColor(label)) },
+ "shellPath": ShellPath,
+ "tagCSS": func() template.CSS { return template.CSS(utils.TagCSS()) },
+ "inc": func(i int) int { return i + 1 },
+ "techMeta": utils.TechMeta,
+ "formatMonthYear": utils.FormatMonthYear,
+ "formatEndDate": utils.FormatEndDate,
+ "formatDuration": utils.FormatDuration,
+ "durationHasYear": utils.DurationHasYear,
+ "timelineYear": utils.TimelineYear,
+ "toHTML": toHTML,
+ "gitCloneURL": config.GitCloneURL,
+ "gitSSHCloneURL": config.GitSSHCloneURL,
+ "wakaTimeEnabled": func() bool { return config.WakaTimeEnabled },
+ "wakaTimeWeeklyEnabled": func() bool { return config.WakaTimeEnabled && config.WakaTimeWeeklyEnabled },
"inlineCSS": func(name string) template.CSS {
if css, ok := inlineStyles[name]; ok {
return css
diff --git a/internal/web/templates/partials/about.gohtml b/internal/web/templates/partials/about.gohtml
@@ -46,7 +46,7 @@
<div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cursor" aria-hidden="true"> </span></div>
</div>
- {{if wakaTimeEnabled}}
+ {{if wakaTimeWeeklyEnabled}}
<div class="section" id="activity-section">
<div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">wakatime --week</span></div>
<div class="weekly-stats" id="activity-stats">