prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

commit 597bbdbb93711fb17a2f311edc57dda853ccfc35
parent 8aa6ccd90a7145441b1ff9d4bb3fb20476e5d2aa
Author: Damian Myrda <damian@prime8.dev>
Date:   Thu, 22 Oct 2026 00:30:13 +0000

feat: jj-style experience graph on web, blinking cursors

web: drop the end node; @ marks the current job, ◆ past ones, with a
hairline through the node column. Stop selectionchange from re-rendering
the prompt on its own DOM writes, which pinned the cursor solid.
tui: block cursor blinks at 530 ms, solid while typing.

Diffstat:
Minternal/tui/program.go | 1+
Minternal/tui/shell.go | 33+++++++++++++++++++++++++++------
Minternal/tui/update.go | 2++
Minternal/web/scripts/modules/shell.js | 11++++++++++-
Minternal/web/styles/experience.css | 62++++++++++++++++++++++----------------------------------------
Minternal/web/templates/partials/experience.gohtml | 6++----
6 files changed, 64 insertions(+), 51 deletions(-)

diff --git a/internal/tui/program.go b/internal/tui/program.go @@ -25,6 +25,7 @@ func (m portfolioModel) Init() tea.Cmd { return tea.Batch( bootTickCmd(), tickCmd(), + cursorBlinkCmd(), initWakaFooterCmd(), ) } diff --git a/internal/tui/shell.go b/internal/tui/shell.go @@ -32,11 +32,13 @@ type shellEntry struct { // shellState is the interactive prompt at the bottom of the about page. type shellState struct { - user string - home string - cwd string - oldpwd string - input string + user string + home string + cwd string + oldpwd string + input string + // lastKey restarts the cursor blink: solid while typing, like a real terminal. + lastKey time.Time history []shellEntry // cleared hides the about-page intro, like `clear` in a real terminal. cleared bool @@ -420,6 +422,7 @@ func commonPrefix(words []string) string { // 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) { + m.shell.lastKey = time.Now() s := &m.shell switch msg.Type { case tea.KeyRunes, tea.KeySpace: @@ -575,7 +578,7 @@ func (m portfolioModel) shellView(width int) string { } parts = append(parts, wrap.Render(out)) } - parts = append(parts, renderShellPrompt(m.shell.user, m.shell.home, m.shell.cwd, m.shell.input)+rowStyle.Render("█")) + parts = append(parts, renderShellPrompt(m.shell.user, m.shell.home, m.shell.cwd, m.shell.input)+m.shell.cursor(time.Now())) return strings.Join(parts, "\n") } @@ -589,3 +592,21 @@ func renderShellPrompt(user, home, cwd, input string) string { promptStyle.Render(" ") + promptCmdStyle.Render(input) } + +// cursorBlink is the half period of the prompt cursor's blink (xterm's 530 ms). +const cursorBlink = 530 * time.Millisecond + +type cursorBlinkMsg struct{} + +func cursorBlinkCmd() tea.Cmd { + return tea.Tick(cursorBlink, func(time.Time) tea.Msg { return cursorBlinkMsg{} }) +} + +// cursor draws the block cursor: solid while keys arrive, then blinking. +func (s shellState) cursor(now time.Time) string { + idle := now.Sub(s.lastKey) + if idle < cursorBlink || (idle/cursorBlink)%2 == 0 { + return rowStyle.Render("█") + } + return " " +} diff --git a/internal/tui/update.go b/internal/tui/update.go @@ -50,6 +50,8 @@ func (m portfolioModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height m.syncViewport() return m, m.refreshDataCmd() + case cursorBlinkMsg: + return m, cursorBlinkCmd() case tickMsg: m.now = time.Time(msg) return m, tea.Batch(tickCmd(), m.refreshDataCmd()) diff --git a/internal/web/scripts/modules/shell.js b/internal/web/scripts/modules/shell.js @@ -172,10 +172,14 @@ export function initShell() { live.scrollIntoView({ block: "nearest" }); } + let lastValue = null; + let lastCaret = -1; function render() { pathEl.textContent = displayPath(sh.home, sh.cwd); const value = input.value; const caret = input.selectionStart ?? value.length; + lastValue = value; + lastCaret = caret; beforeEl.textContent = value.slice(0, caret); cursorEl.textContent = value.slice(caret, caret + 1) || "\u00a0"; afterEl.textContent = value.slice(caret + 1); @@ -725,8 +729,13 @@ export function initShell() { input.addEventListener("input", render); input.addEventListener("select", render); + // Only a real caret move re-renders: render() rewrites the cursor spans, + // which itself fires selectionchange, and re-rendering on that kept the + // cursor pinned solid forever (it never got to blink). document.addEventListener("selectionchange", () => { - if (document.activeElement === input) render(); + if (document.activeElement !== input) return; + if (input.value === lastValue && input.selectionStart === lastCaret) return; + render(); }); input.addEventListener("focus", () => { section.classList.add("focused"); diff --git a/internal/web/styles/experience.css b/internal/web/styles/experience.css @@ -42,18 +42,13 @@ } /* - * `git log --graph` rail, drawn entirely in monospace text: + * jj log style graph rail: * - * * [now] <- .graph-node + .timeline-year (accent when active) - * | <- .graph-rail: a column of "|" glyphs, clipped to the row - * | - * * [2025] <- next row's node, same character column + * @ [now] <- current job (jj's working copy) + * │ <- .graph-rail: 1px line through the node's character cell + * ◆ [2026] <- past jobs (immutable history) * - * The node and the rail share one font-size and one left offset, so the "*" - * and every "|" land in the same character cell. Each rail segment is a - * pre-formatted stack of "|" lines (generated content) clipped by its box, - * so it stretches to any card height and stays crisp at every zoom. The rail - * is dim; the selected job's node, year and rail go accent. + * The selected job's node and year go accent, its line accent-dim. */ .exp-timeline { --row: 1.5em; @@ -85,45 +80,32 @@ color: var(--fg-dim); } -/* segment below the node, down to the card bottom (or the start node) */ -.graph-rail { +/* hairline through the node column */ +.graph-rail, +.exp-row + .exp-row .exp-timeline::before { position: absolute; - left: 0; - top: calc(24px + var(--row)); - bottom: 0; - width: 1ch; - overflow: hidden; - color: var(--fg-dim); + /* centre of the node's character cell, so the line runs through it */ + left: calc(0.5ch - 0.5px); + width: 1px; + background: var(--border); } -.graph-rail::before, -.exp-row + .exp-row .exp-timeline::before { - content: "|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A|\A"; - display: block; - white-space: pre; +/* from under this job's node to the bottom of its row */ +.graph-rail { + top: calc(24px + var(--row)); + bottom: 0; } -/* segment above the node, bridging the 16px row gap from the row above */ +/* across the gap between cards, down to the next node */ .exp-row + .exp-row .exp-timeline::before { - position: absolute; - left: 0; + content: ""; top: -16px; height: calc(16px + 24px); - width: 1ch; - overflow: hidden; - color: var(--fg-dim); -} - -/* start node on the card's last row; the rail stops just above it */ -.graph-node-end { - position: absolute; - left: 0; - bottom: 0; - height: var(--row); } -.exp-timeline.has-end-dot .graph-rail { - bottom: var(--row); +/* oldest job: nothing below it */ +.exp-row:last-child .graph-rail { + display: none; } .exp-timeline.active .graph-node, @@ -132,7 +114,7 @@ } .exp-timeline.active .graph-rail { - color: var(--accent-dim); + background: var(--accent-dim); } .exp-card-header { diff --git a/internal/web/templates/partials/experience.gohtml b/internal/web/templates/partials/experience.gohtml @@ -3,12 +3,10 @@ <article class="experience-layout"> <div class="experience-detail"> {{range $index, $exp := .Experience}} - {{$hasYear := durationHasYear $exp.StartDate $exp.EndDate $exp.Current}} <div class="exp-row" data-index="{{$index}}" data-id="{{$exp.ID}}"> - <div class="exp-timeline{{if $.SelectedExperienceID}}{{if eq $exp.ID $.SelectedExperienceID}} active{{end}}{{else}}{{if eq $index 0}} active{{end}}{{end}}{{if $hasYear}} has-end-dot{{end}}" data-index="{{$index}}"> - <div class="graph-head"><span class="graph-node" aria-hidden="true">*</span> <span class="timeline-year">[{{timelineYear $exp.EndDate $exp.Current}}]</span></div> + <div class="exp-timeline{{if $.SelectedExperienceID}}{{if eq $exp.ID $.SelectedExperienceID}} active{{end}}{{else}}{{if eq $index 0}} active{{end}}{{end}}" data-index="{{$index}}"> + <div class="graph-head"><span class="graph-node" aria-hidden="true">{{if $exp.Current}}@{{else}}◆{{end}}</span> <span class="timeline-year">[{{timelineYear $exp.EndDate $exp.Current}}]</span></div> <span class="graph-rail" aria-hidden="true"></span> - {{if $hasYear}}<span class="graph-node graph-node-end" aria-hidden="true">*</span>{{end}} </div> <div class="exp-card{{if $.SelectedExperienceID}}{{if eq $exp.ID $.SelectedExperienceID}} selected{{end}}{{else}}{{if eq $index 0}} selected{{end}}{{end}}" data-index="{{$index}}" data-id="{{$exp.ID}}">