From 162468c0f7ed65c8e2a15f54a999e52db878362c Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 20 Apr 2026 16:27:14 -0700 Subject: [PATCH] commits: text UI buttons + Tangled feed via /api/commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all piece-specific keyboard shortcuts with ui.TextButtons (tangled link, compact/detail, play/pause, refresh) matching the rest of AC. Fetch from a new /api/commits endpoint that runs git log on the Tangled remote instead of hitting GitHub's API directly, and fix the author truncation so "prompt.ac/@jeffrey" renders as "@jeffrey". Also label netlify.toml as deprecated — lith is the host now. Co-Authored-By: Claude Opus 4.7 (1M context) --- system/netlify.toml | 6 + system/netlify/functions/commits.mjs | 151 ++++ .../aesthetic.computer/disks/commits.mjs | 740 +++++++----------- 3 files changed, 429 insertions(+), 468 deletions(-) create mode 100644 system/netlify/functions/commits.mjs diff --git a/system/netlify.toml b/system/netlify.toml index 4183d138a9..fd2d09b420 100644 --- a/system/netlify.toml +++ b/system/netlify.toml @@ -1,3 +1,9 @@ +# DEPRECATED: Aesthetic Computer no longer deploys on Netlify. The production +# host is the lith Express monolith at lith.aesthetic.computer (see CLAUDE.md). +# This file is retained for historical reference only — do not add new routes +# here. Backend handlers still live in system/netlify/functions/ (the path is +# historical), and lith adapts them via app.all("/api/:fn", …) at runtime. + [build] base = "system" publish = "public" diff --git a/system/netlify/functions/commits.mjs b/system/netlify/functions/commits.mjs new file mode 100644 index 0000000000..fc07a774af --- /dev/null +++ b/system/netlify/functions/commits.mjs @@ -0,0 +1,151 @@ +// Returns paginated commits from the Tangled remote (git log via local mirror). +// Source of truth: tangled.org/aesthetic.computer/core (mirrored on knot.aesthetic.computer). + +import fs from "fs"; +import path from "path"; +import { execFile } from "child_process"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); +const GIT_BRANCH = process.env.VERSION_GIT_BRANCH || "main"; +const MAX_PER_PAGE = 100; +const FETCH_TTL_MS = 60 * 1000; + +let lastFetchAt = 0; + +function getRepoRoot() { + const candidates = [ + path.resolve(process.cwd(), ".."), + process.cwd(), + ]; + return candidates.find((c) => fs.existsSync(path.join(c, ".git"))) || null; +} + +async function git(args, repoRoot) { + const { stdout } = await execFileAsync("git", ["-C", repoRoot, ...args], { + timeout: 15000, + maxBuffer: 8 * 1024 * 1024, + }); + return stdout; +} + +async function getPreferredRemote(repoRoot) { + if (process.env.VERSION_GIT_REMOTE) return process.env.VERSION_GIT_REMOTE; + const remotes = (await git(["remote"], repoRoot)) + .split("\n") + .map((r) => r.trim()) + .filter(Boolean); + if (remotes.includes("tangled")) return "tangled"; + if (remotes.includes("origin")) return "origin"; + return remotes[0] || "origin"; +} + +async function maybeFetch(remote, repoRoot) { + if (Date.now() - lastFetchAt < FETCH_TTL_MS) return; + try { + await git(["fetch", "--quiet", remote, GIT_BRANCH], repoRoot); + lastFetchAt = Date.now(); + } catch { + // Ignore; stale data is better than an error. + } +} + +function parseCommits(raw) { + const blocks = raw.split("<>").map((b) => b.trim()).filter(Boolean); + const commits = []; + for (const block of blocks) { + const lines = block.split("\n"); + const header = lines[0]; + const parts = header.split("|"); + if (parts.length < 6) continue; + const [sha, parentField, author, email, date, ...messageParts] = parts; + const message = messageParts.join("|"); + + let additions = 0; + let deletions = 0; + let files = 0; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + const [a, d, f] = line.split("\t"); + if (!f) continue; + files += 1; + if (a === "-" || d === "-") continue; + additions += Number(a) || 0; + deletions += Number(d) || 0; + } + + commits.push({ + sha, + shortSha: sha.slice(0, 7), + parents: parentField ? parentField.split(" ").filter(Boolean).length : 0, + author, + email, + date, + message, + additions, + deletions, + files, + }); + } + return commits; +} + +export default async (request) => { + const url = new URL(request.url); + const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10)); + const perPage = Math.min( + MAX_PER_PAGE, + Math.max(1, parseInt(url.searchParams.get("per_page") || "30", 10)), + ); + const skip = (page - 1) * perPage; + + const repoRoot = getRepoRoot(); + if (!repoRoot) { + return new Response( + JSON.stringify({ error: "repo not found", commits: [] }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } + + try { + const remote = await getPreferredRemote(repoRoot); + await maybeFetch(remote, repoRoot); + + const raw = await git( + [ + "log", + `${remote}/${GIT_BRANCH}`, + `--skip=${skip}`, + `--max-count=${perPage}`, + "--pretty=format:<>%H|%P|%an|%ae|%cI|%s", + "--numstat", + ], + repoRoot, + ); + + const commits = parseCommits(raw); + + return new Response( + JSON.stringify({ + page, + perPage, + hasMore: commits.length === perPage, + commits, + }), + { + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=60", + }, + }, + ); + } catch (e) { + return new Response( + JSON.stringify({ error: e.message, commits: [] }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ); + } +}; + +export const config = { path: "/api/commits" }; diff --git a/system/public/aesthetic.computer/disks/commits.mjs b/system/public/aesthetic.computer/disks/commits.mjs index 3947e99728..20fb87939f 100644 --- a/system/public/aesthetic.computer/disks/commits.mjs +++ b/system/public/aesthetic.computer/disks/commits.mjs @@ -1,14 +1,12 @@ // commits, 2025.1.14 -// Live GitHub commit feed for aesthetic-computer -// ╔═══════════════════════════════════════════════════════════╗ -// ║ A typographically ornate commit history visualization ║ -// ╚═══════════════════════════════════════════════════════════╝ +// Live Tangled commit feed for aesthetic.computer/core. +// Source: https://tangled.org/aesthetic.computer/core (via /api/commits) -const { max, min, floor, ceil, abs, sin, cos, PI } = Math; +const { max, min, floor, sin } = Math; -const REPO = "whistlegraph/aesthetic-computer"; +const TANGLED_REPO_URL = "https://tangled.org/aesthetic.computer/core"; const POLL_INTERVAL = 30000; // 30 seconds -const COMMITS_PER_PAGE = 30; // Fewer per page since we fetch details +const COMMITS_PER_PAGE = 30; let commits = []; let scroll = 0; @@ -19,37 +17,31 @@ let loadingMore = false; let error = null; let lastFetch = 0; let pollTimer = null; -let rowHeight = 10; // MatrixChunky8 is 8px + 2px spacing for elegance -let topMargin = 18; // Below HUD label -let bottomMargin = 20; // Footer area +let rowHeight = 10; +let topMargin = 18; +let bottomMargin = 22; let hue = 0; let pulsePhase = 0; let needsLayout = true; -let autoScroll = false; // Start paused -let autoScrollDelay = 2000; // 2 second delay before auto-scroll -let loadTime = 0; // When commits first loaded +let autoScroll = false; +let autoScrollDelay = 2000; +let loadTime = 0; let autoScrollSpeed = 0.25; let currentPage = 1; let hasMoreCommits = true; -let showDetailedView = true; // Toggle detailed stats +let showDetailedView = true; let frameCount = 0; -let hoveredCommit = null; -let selectedCommit = null; -// Stats cache for detailed commit info -const statsCache = new Map(); -const statsFetching = new Set(); +// UI buttons (created in boot, painted in paint, handled in act). +let tangledBtn = null; +let detailBtn = null; +let playBtn = null; +let refreshBtn = null; -// GitHub link box for click detection -let githubLinkBox = null; - -// Visual theming const FONT = "MatrixChunky8"; const COLORS = { bg: [10, 12, 18], - bgAccent: [16, 18, 26], line: [35, 40, 55], - lineAccent: [50, 55, 75], sha: [255, 180, 100], shaNew: [150, 255, 150], author: [180, 150, 255], @@ -64,12 +56,11 @@ const COLORS = { day: [120, 140, 160], }; -// Parse relative time +// Parse relative time. function timeAgo(dateStr) { const now = new Date(); const past = new Date(dateStr); const seconds = Math.floor((now - past) / 1000); - const units = [ { name: "y", seconds: 31536000 }, { name: "mo", seconds: 2592000 }, @@ -79,7 +70,6 @@ function timeAgo(dateStr) { { name: "m", seconds: 60 }, { name: "s", seconds: 1 }, ]; - for (const unit of units) { const count = Math.floor(seconds / unit.seconds); if (count >= 1) return `${count}${unit.name}`; @@ -87,24 +77,31 @@ function timeAgo(dateStr) { return "now"; } -// Format numbers with commas function formatNum(n) { if (n >= 1000) return (n / 1000).toFixed(1) + "k"; return String(n); } -// Generate sparkline bar for additions/deletions +// Extract a handle-like label from git author names. Tangled commits are +// authored as e.g. "prompt.ac/@jeffrey" — we want the `@jeffrey` part. +function formatAuthor(name) { + if (!name) return "@?"; + const atMatch = name.match(/@([A-Za-z0-9._-]+)/); + if (atMatch) return "@" + atMatch[1]; + const first = name.split(/\s+/)[0].toLowerCase(); + return "@" + first; +} + function statsBar(add, del, maxWidth = 30) { const total = add + del; if (total === 0) return { addW: 0, delW: 0 }; - const scale = min(1, total / 200); // Scale to max 200 changes + const scale = min(1, total / 200); const w = floor(maxWidth * scale); const addW = total > 0 ? max(1, floor((add / total) * w)) : 0; const delW = total > 0 ? max(1, floor((del / total) * w)) : 0; return { addW, delW }; } -// Bound scroll like chat.mjs does function boundScroll() { if (scroll < 0) scroll = 0; if (scroll > totalScrollHeight - chatHeight + 5) { @@ -112,49 +109,18 @@ function boundScroll() { } } -// Fetch detailed stats for a single commit -async function fetchCommitStats(sha) { - if (statsCache.has(sha) || statsFetching.has(sha)) return; - statsFetching.add(sha); - - try { - const response = await fetch( - `https://api.github.com/repos/${REPO}/commits/${sha}` - ); - if (response.ok) { - const data = await response.json(); - statsCache.set(sha, { - additions: data.stats?.additions || 0, - deletions: data.stats?.deletions || 0, - files: data.files?.length || 0, - }); - } - } catch (e) { - console.warn("Failed to fetch commit stats:", sha, e); - } finally { - statsFetching.delete(sha); - } -} - -// Get timeline marker info for a date function getTimelineMarker(dateStr, prevDateStr) { const d = new Date(dateStr); const prev = prevDateStr ? new Date(prevDateStr) : null; - const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - - // Check for year change (biggest) + if (!prev || d.getFullYear() !== prev.getFullYear()) { return { type: "year", label: `◆ ${d.getFullYear()} ◆`, color: COLORS.year }; } - - // Check for month change if (d.getMonth() !== prev.getMonth()) { return { type: "month", label: `── ${months[d.getMonth()]} ──`, color: COLORS.month }; } - - // Check for week change (Sunday boundary) const weekOfYear = (date) => { const start = new Date(date.getFullYear(), 0, 1); return Math.ceil(((date - start) / 86400000 + start.getDay() + 1) / 7); @@ -162,79 +128,58 @@ function getTimelineMarker(dateStr, prevDateStr) { if (weekOfYear(d) !== weekOfYear(prev)) { return { type: "week", label: `· Week ${weekOfYear(d)} ·`, color: COLORS.week }; } - - // Check for day change if (d.getDate() !== prev.getDate()) { return { type: "day", label: `${days[d.getDay()]} ${d.getDate()}`, color: COLORS.day }; } - return null; } -// Fetch commits from GitHub API async function fetchCommits(page = 1, append = false) { try { if (page === 1) loading = commits.length === 0; else loadingMore = true; - - const response = await fetch( - `https://api.github.com/repos/${REPO}/commits?per_page=${COMMITS_PER_PAGE}&page=${page}` - ); - - if (!response.ok) { - throw new Error(`GitHub API error: ${response.status}`); - } - + + const response = await fetch(`/api/commits?page=${page}&per_page=${COMMITS_PER_PAGE}`); + if (!response.ok) throw new Error(`commits API error: ${response.status}`); const data = await response.json(); - - // Check if we got fewer commits than requested (end of history) - if (data.length < COMMITS_PER_PAGE) { - hasMoreCommits = false; - } - - const newCommits = data.map(c => ({ - sha: c.sha.slice(0, 7), + if (data.error) throw new Error(data.error); + + hasMoreCommits = !!data.hasMore; + + const newCommits = (data.commits || []).map((c) => ({ + sha: c.shortSha, fullSha: c.sha, - message: c.commit.message.split("\n")[0], // First line only - fullMessage: c.commit.message, // Keep full message for expanded view - author: c.commit.author.name, - email: c.commit.author.email, - date: c.commit.author.date, - avatar: c.author?.avatar_url, - parents: c.parents?.length || 0, // For merge detection + message: (c.message || "").split("\n")[0], + fullMessage: c.message || "", + author: c.author, + email: c.email, + date: c.date, + parents: c.parents || 0, + additions: c.additions || 0, + deletions: c.deletions || 0, + files: c.files || 0, })); - + if (append) { - // Filter out duplicates - const existingShas = new Set(commits.map(c => c.fullSha)); - const unique = newCommits.filter(c => !existingShas.has(c.fullSha)); - commits = [...commits, ...unique]; + const existing = new Set(commits.map((c) => c.fullSha)); + commits = [...commits, ...newCommits.filter((c) => !existing.has(c.fullSha))]; } else { - // Check for new commits at top const hadCommits = commits.length > 0; const oldFirstSha = commits[0]?.fullSha; commits = newCommits; - - // Flash if new commits arrived if (hadCommits && commits[0]?.fullSha !== oldFirstSha) { - hue = 120; // Flash green for new commit + hue = 120; } } - - // Fetch stats for visible commits - newCommits.slice(0, 10).forEach(c => fetchCommitStats(c.fullSha)); - + lastFetch = Date.now(); loading = false; loadingMore = false; error = null; needsLayout = true; currentPage = page; - - // Track when commits first loaded for auto-scroll delay - if (page === 1 && !append && loadTime === 0) { - loadTime = Date.now(); - } + + if (page === 1 && !append && loadTime === 0) loadTime = Date.now(); } catch (err) { error = err.message; loading = false; @@ -243,97 +188,74 @@ async function fetchCommits(page = 1, append = false) { } } -// Load more commits when scrolling near bottom async function loadMoreIfNeeded() { if (loadingMore || !hasMoreCommits) return; - - const scrollNearBottom = scroll > totalScrollHeight - chatHeight - 200; - if (scrollNearBottom) { + if (scroll > totalScrollHeight - chatHeight - 200) { await fetchCommits(currentPage + 1, true); } } -function boot({ screen, store }) { - // Initial fetch +function buildButtons({ ui, screen }) { + tangledBtn = new ui.TextButton("tangled", { right: 4, top: 4, screen }); + detailBtn = new ui.TextButton(showDetailedView ? "compact" : "detail", { + left: 4, + bottom: 4, + screen, + }); + playBtn = new ui.TextButton(autoScroll ? "pause" : "play", { + left: 4 + detailBtn.width + 4, + bottom: 4, + screen, + }); + refreshBtn = new ui.TextButton("refresh", { right: 4, bottom: 4, screen }); +} + +function syncButtons({ screen }) { + if (!tangledBtn) return; + // Labels track state. + detailBtn.replaceLabel(showDetailedView ? "compact" : "detail"); + playBtn.replaceLabel(autoScroll ? "pause" : "play"); + // Reposition (widths change with label). + tangledBtn.reposition({ right: 4, top: 4, screen }); + refreshBtn.reposition({ right: 4, bottom: 4, screen }); + detailBtn.reposition({ left: 4, bottom: 4, screen }); + playBtn.reposition({ left: 4 + detailBtn.width + 4, bottom: 4, screen }); +} + +// Color schemes: [fill, outline, text] / [hover fill, hover outline, hover text] +const BTN_SCHEME = [[20, 24, 34], [80, 90, 120], [180, 190, 220]]; +const BTN_HOVER = [[30, 35, 50], [140, 160, 210], [220, 230, 255]]; +const LINK_SCHEME = [[0, 0, 0, 0], [40, 60, 110], [130, 170, 255]]; +const LINK_HOVER = [[20, 30, 60], [140, 180, 255], [220, 235, 255]]; +const PLAY_SCHEME = [[14, 28, 20], [60, 160, 100], [140, 230, 170]]; +const PLAY_HOVER = [[20, 40, 30], [110, 220, 150], [200, 255, 220]]; + +function boot({ ui, screen }) { fetchCommits(); - - // Start polling for new commits pollTimer = setInterval(() => fetchCommits(1, false), POLL_INTERVAL); - - // Always start at top with fresh state scroll = 0; autoScroll = false; loadTime = 0; frameCount = 0; + buildButtons({ ui, screen }); + topMargin = 4 + (tangledBtn?.height || 18) + 4; + bottomMargin = 4 + (detailBtn?.height || 18) + 4; } -// Draw decorative elements -function drawDecor(ink, line, box, w, h, phase) { - // Subtle corner decorations - const cornerSize = 6; - const c = [40, 45, 60, 150 + sin(phase) * 30]; - - // Top-left corner - ink(...c).line(0, cornerSize, 0, 0); - ink(...c).line(0, 0, cornerSize, 0); - - // Top-right corner - ink(...c).line(w - cornerSize, 0, w - 1, 0); - ink(...c).line(w - 1, 0, w - 1, cornerSize); - - // Bottom-left corner - ink(...c).line(0, h - cornerSize, 0, h - 1); - ink(...c).line(0, h - 1, cornerSize, h - 1); - - // Bottom-right corner - ink(...c).line(w - cornerSize, h - 1, w - 1, h - 1); - ink(...c).line(w - 1, h - cornerSize, w - 1, h - 1); -} - -// Render stats bars with smooth animation -function drawStats(ink, box, x, y, stats, w) { - if (!stats) return y; - - const { addW, delW } = statsBar(stats.additions, stats.deletions, min(w - 60, 40)); - const barY = y; - const barH = 4; - - // Stats bar background - ink(25, 28, 35).box(x, barY, addW + delW + 2, barH); - - // Additions bar (green) - if (addW > 0) { - ink(...COLORS.additions).box(x, barY, addW, barH); - } - - // Deletions bar (red) - if (delW > 0) { - ink(...COLORS.deletions).box(x + addW, barY, delW, barH); - } - - return barY + barH + 2; -} - -function paint({ wipe, ink, screen, line, text, box, typeface, num, needsPaint, mask, unmask }) { +function paint($) { + const { wipe, ink, screen, line, text, box, needsPaint, mask, unmask, ui } = $; const { width: w, height: h } = screen; frameCount++; pulsePhase += 0.05; - - // Rich dark background with subtle gradient simulation + const bgPulse = sin(pulsePhase * 0.3) * 2; wipe(COLORS.bg[0] + bgPulse, COLORS.bg[1] + bgPulse, COLORS.bg[2] + bgPulse); - - // Draw decorative corner elements - drawDecor(ink, line, box, w, h, pulsePhase); - - // Header: GitHub link right only (HUD handles piece name) - const headerY = 8; - const ghText = "GitHub →"; - const ghTextW = text.width(ghText, FONT); - const ghX = w - ghTextW - 4; - const ghGlow = 150 + sin(pulsePhase * 1.5) * 40; - ink(100, 140, 255, ghGlow).write(ghText, { x: ghX, y: headerY }, false, undefined, false, FONT); - githubLinkBox = { x: ghX - 2, y: headerY - 2, w: ghTextW + 4, h: 12 }; + + // Ensure buttons exist (in case of hot-reload) and keep them in sync. + if (!tangledBtn) buildButtons({ ui, screen }); + syncButtons({ screen }); + topMargin = 4 + tangledBtn.height + 4; + bottomMargin = 4 + detailBtn.height + 4; // Top divider line with gradient effect const topLineY = topMargin - 1; @@ -341,38 +263,50 @@ function paint({ wipe, ink, screen, line, text, box, typeface, num, needsPaint, const alpha = 40 + sin(i * 0.02 + pulsePhase) * 15; ink(50, 55, 75, alpha).box(i, topLineY, 1, 1); } - + // Bottom divider line const botLineY = h - bottomMargin; for (let i = 0; i < w; i++) { const alpha = 40 + sin(i * 0.02 - pulsePhase) * 15; ink(50, 55, 75, alpha).box(i, botLineY, 1, 1); } - + if (loading && commits.length === 0) { - // Animated loading indicator const dots = ".".repeat((floor(frameCount / 10) % 4)); - const loadText = `Loading commits${dots}`; - ink(150, 150, 180).write(loadText, { center: "xy", x: w / 2, y: h / 2 }, false, undefined, false, FONT); + ink(150, 150, 180).write( + `Loading commits${dots}`, + { center: "xy", x: w / 2, y: h / 2 }, + false, + undefined, + false, + FONT, + ); + paintButtons($); needsPaint(); return; } - + if (error && commits.length === 0) { - ink(255, 100, 100).write("Error: " + error.slice(0, 40), { center: "xy", x: w / 2, y: h / 2 }, false, undefined, false, FONT); + ink(255, 100, 100).write( + "Error: " + error.slice(0, 40), + { center: "xy", x: w / 2, y: h / 2 }, + false, + undefined, + false, + FONT, + ); + paintButtons($); return; } - - // Calculate heights - expanded view with stats + chatHeight = h - topMargin - bottomMargin; - const baseCommitHeight = rowHeight + 2; // Single row per commit - const expandedCommitHeight = rowHeight * 4 + 4; // Space for stats (no file list) + const baseCommitHeight = rowHeight + 2; + const expandedCommitHeight = rowHeight * 4 + 4; const commitHeight = showDetailedView ? expandedCommitHeight : baseCommitHeight; - const yearMarkerHeight = 18; // More prominent year markers + const yearMarkerHeight = 18; const monthMarkerHeight = 14; const smallMarkerHeight = rowHeight + 4; - - // Calculate total height including timeline markers + if (needsLayout) { let height = 0; for (let i = 0; i < commits.length; i++) { @@ -388,75 +322,59 @@ function paint({ wipe, ink, screen, line, text, box, typeface, num, needsPaint, totalScrollHeight = height; needsLayout = false; } - - // Mask off the scrollable area - mask({ - x: 0, - y: topMargin, - width: w, - height: chatHeight, - }); - - // Fetch stats for visible commits - let visibleStart = floor(scroll / commitHeight); - let visibleEnd = ceil((scroll + chatHeight) / commitHeight); - for (let i = max(0, visibleStart - 2); i < min(commits.length, visibleEnd + 2); i++) { - const c = commits[i]; - if (c && !statsCache.has(c.fullSha) && !statsFetching.has(c.fullSha)) { - fetchCommitStats(c.fullSha); - } - } - - // Draw commits with timeline markers + + mask({ x: 0, y: topMargin, width: w, height: chatHeight }); + let y = topMargin - scroll; - + for (let i = 0; i < commits.length; i++) { const commit = commits[i]; const prevDate = i > 0 ? commits[i - 1].date : null; const marker = getTimelineMarker(commit.date, prevDate); - const stats = statsCache.get(commit.fullSha); - - // Timeline marker + if (marker) { let markerH = smallMarkerHeight; - - if (marker.type === "year") { - markerH = yearMarkerHeight; - } else if (marker.type === "month") { - markerH = monthMarkerHeight; - } - + if (marker.type === "year") markerH = yearMarkerHeight; + else if (marker.type === "month") markerH = monthMarkerHeight; + if (y + markerH >= topMargin - markerH && y < h - bottomMargin + markerH) { - // Background for marker with gradient - const bgAlpha = marker.type === "year" ? 220 : (marker.type === "month" ? 180 : 100); + const bgAlpha = marker.type === "year" ? 220 : marker.type === "month" ? 180 : 100; ink(30, 28, 45, bgAlpha).box(0, y, w, markerH); - - // Marker text const textY = y + Math.floor((markerH - 8) / 2); - + if (marker.type === "year") { - // Year: centered, ornate const yearGlow = 180 + sin(pulsePhase * 2) * 40; ink(marker.color[0], marker.color[1], marker.color[2], yearGlow).write( - marker.label, { center: "x", x: w / 2, y: textY }, false, undefined, false, FONT + marker.label, + { center: "x", x: w / 2, y: textY }, + false, + undefined, + false, + FONT, ); } else if (marker.type === "month") { - // Month: left aligned with accent line ink(...marker.color).write(marker.label, { x: 4, y: textY }, false, undefined, false, FONT); - // Accent line const labelW = text.width(marker.label, FONT) + 8; - ink(marker.color[0], marker.color[1], marker.color[2], 60).line(labelW, y + markerH / 2, w - 4, y + markerH / 2); + ink(marker.color[0], marker.color[1], marker.color[2], 60).line( + labelW, + y + markerH / 2, + w - 4, + y + markerH / 2, + ); } else { - // Week/Day: subtle with dot ink(marker.color[0], marker.color[1], marker.color[2], 180).write( - marker.label, { x: 4, y: textY }, false, undefined, false, FONT + marker.label, + { x: 4, y: textY }, + false, + undefined, + false, + FONT, ); } } y += markerH; } - - // Skip if outside visible area (with buffer) + if (y + commitHeight < topMargin - 20) { y += commitHeight; continue; @@ -465,60 +383,47 @@ function paint({ wipe, ink, screen, line, text, box, typeface, num, needsPaint, y += commitHeight; continue; } - - // Commit row background (alternating subtle stripes with inner padding) + if (showDetailedView) { const rowPadding = 3; if (i % 2 === 0) { ink(18, 20, 28, 100).box(0, y + rowPadding, w, commitHeight - rowPadding * 2); } - // Subtle separator line between commits ink(40, 45, 60, 40).line(4, y + commitHeight - 1, w - 4, y + commitHeight - 1); - } else { - if (i % 2 === 0) { - ink(18, 20, 28, 60).box(0, y, w, commitHeight); - } + } else if (i % 2 === 0) { + ink(18, 20, 28, 60).box(0, y, w, commitHeight); } - - // Row 1: SHA, time, author (with vertical offset for padding) - const contentY = y + (showDetailedView ? 4 : 1); // Top padding within commit block + + const contentY = y + (showDetailedView ? 4 : 1); const isNew = i === 0 && hue > 60; const shaPulse = isNew ? 200 + sin(pulsePhase * 4) * 55 : 0; const shaColor = isNew ? [150 + shaPulse * 0.4, 255, 150 + shaPulse * 0.2] : COLORS.sha; - - // SHA with subtle box + ink(30, 32, 42).box(2, contentY, 34, rowHeight); ink(...shaColor).write(commit.sha, { x: 4, y: contentY + 1 }, false, undefined, false, FONT); - - // Merge indicator + let xOffset = 38; if (commit.parents > 1) { ink(180, 140, 200).write("⊕", { x: xOffset, y: contentY + 1 }, false, undefined, false, FONT); xOffset += 10; } - - // Time ago + const ago = timeAgo(commit.date); ink(...COLORS.time).write(ago, { x: xOffset, y: contentY + 1 }, false, undefined, false, FONT); - xOffset += text.width(ago + " ", FONT); // Extra space - - // Author (truncate to fit) - const author = "@" + commit.author.split(" ")[0].toLowerCase().slice(0, 12); + xOffset += text.width(ago + " ", FONT); + + const author = formatAuthor(commit.author); ink(...COLORS.author).write(author, { x: xOffset, y: contentY + 1 }, false, undefined, false, FONT); const authorEndX = xOffset + text.width(author, FONT); - const charWidth = 4; if (!showDetailedView) { - // Single-row mode: ticker message after author const msgX = authorEndX + 6; const availableChars = Math.floor((w - msgX - 8) / charWidth); const msg = commit.message; - if (msg.length <= availableChars) { ink(...COLORS.message).write(msg, { x: msgX, y: contentY + 1 }, false, undefined, false, FONT); } else { - // Ticker: scroll the message with seamless wrap const separator = " · "; const fullTicker = msg + separator; const tickerLen = fullTicker.length; @@ -531,266 +436,165 @@ function paint({ wipe, ink, screen, line, text, box, typeface, num, needsPaint, } if (showDetailedView) { - // Row 2: Message (with extra line spacing) - const msgY = contentY + rowHeight + 2; - const maxChars = Math.floor((w - 8) / charWidth); - const msg = commit.message.slice(0, maxChars); - ink(...COLORS.message).write(msg, { x: 4, y: msgY }, false, undefined, false, FONT); - - // Row 3-4: Stats (if detailed view and stats loaded) - { - const statsY = contentY + rowHeight * 2 + 4; // Extra spacing before stats - - if (stats) { - // Stats line: +additions -deletions files - let sx = 4; - - // Additions - const addText = `+${formatNum(stats.additions)}`; - ink(...COLORS.additions).write(addText, { x: sx, y: statsY }, false, undefined, false, FONT); - sx += text.width(addText + " ", FONT); - - // Deletions - const delText = `-${formatNum(stats.deletions)}`; - ink(...COLORS.deletions).write(delText, { x: sx, y: statsY }, false, undefined, false, FONT); - sx += text.width(delText + " ", FONT); - - // Files changed - const filesText = `${stats.files}f`; - ink(...COLORS.files).write(filesText, { x: sx, y: statsY }, false, undefined, false, FONT); - sx += text.width(filesText + " ", FONT); - - // Mini bar chart - const { addW, delW } = statsBar(stats.additions, stats.deletions, 35); - if (addW > 0) { - ink(...COLORS.additions, 180).box(sx, statsY + 2, addW, 4); - } - if (delW > 0) { - ink(...COLORS.deletions, 180).box(sx + addW, statsY + 2, delW, 4); - } - - } else if (statsFetching.has(commit.fullSha)) { - // Loading indicator for stats - const loadDots = ".".repeat((floor(frameCount / 8) % 4)); - ink(80, 80, 100).write(`loading${loadDots}`, { x: 4, y: statsY }, false, undefined, false, FONT); - } else { - // Placeholder - ink(50, 50, 60).write("···", { x: 4, y: statsY }, false, undefined, false, FONT); - } - } - } // end showDetailedView + const msgY = contentY + rowHeight + 2; + const maxChars = Math.floor((w - 8) / charWidth); + const msg = commit.message.slice(0, maxChars); + ink(...COLORS.message).write(msg, { x: 4, y: msgY }, false, undefined, false, FONT); + + const statsY = contentY + rowHeight * 2 + 4; + let sx = 4; + + const addText = `+${formatNum(commit.additions)}`; + ink(...COLORS.additions).write(addText, { x: sx, y: statsY }, false, undefined, false, FONT); + sx += text.width(addText + " ", FONT); + + const delText = `-${formatNum(commit.deletions)}`; + ink(...COLORS.deletions).write(delText, { x: sx, y: statsY }, false, undefined, false, FONT); + sx += text.width(delText + " ", FONT); + + const filesText = `${commit.files}f`; + ink(...COLORS.files).write(filesText, { x: sx, y: statsY }, false, undefined, false, FONT); + sx += text.width(filesText + " ", FONT); + + const { addW, delW } = statsBar(commit.additions, commit.deletions, 35); + if (addW > 0) ink(...COLORS.additions, 180).box(sx, statsY + 2, addW, 4); + if (delW > 0) ink(...COLORS.deletions, 180).box(sx + addW, statsY + 2, delW, 4); - // Subtle separator with gradient (detail view only) - if (showDetailedView) { const sepY = y + commitHeight - 1; - for (let sx = 4; sx < w - 4; sx++) { - const alpha = 20 + sin(sx * 0.1) * 10; - ink(35, 38, 50, alpha).box(sx, sepY, 1, 1); + for (let sxp = 4; sxp < w - 4; sxp++) { + const alpha = 20 + sin(sxp * 0.1) * 10; + ink(35, 38, 50, alpha).box(sxp, sepY, 1, 1); } } - + y += commitHeight; } - - // Loading more indicator at bottom + if (loadingMore) { const loadDots = ".".repeat((floor(frameCount / 10) % 4)); - ink(150, 150, 180).write(`Loading more${loadDots}`, { x: 4, y: h - bottomMargin - rowHeight - 4 }, false, undefined, false, FONT); + ink(150, 150, 180).write( + `Loading more${loadDots}`, + { x: 4, y: h - bottomMargin - rowHeight - 4 }, + false, + undefined, + false, + FONT, + ); } - - unmask(); // End masking - - // 📜 Scroll bar (ornate version) + + unmask(); + + // 📜 Scroll bar if (totalScrollHeight > chatHeight) { - // Track ink(25, 28, 35).box(w - 4, topMargin, 3, chatHeight); - - // Decorative track edges ink(40, 45, 55).line(w - 5, topMargin, w - 5, topMargin + chatHeight); ink(40, 45, 55).line(w - 1, topMargin, w - 1, topMargin + chatHeight); - const segHeight = max(8, floor((chatHeight / totalScrollHeight) * chatHeight)); const scrollRatio = scroll / max(1, totalScrollHeight - chatHeight); const boxY = topMargin + floor(scrollRatio * (chatHeight - segHeight)); - - // Thumb with glow const thumbColor = autoScroll ? COLORS.additions : [200, 150, 255]; const thumbGlow = 50 + sin(pulsePhase * 2) * 30; ink(thumbColor[0], thumbColor[1], thumbColor[2], thumbGlow).box(w - 6, boxY - 1, 7, segHeight + 2); ink(...thumbColor).box(w - 4, boxY, 3, segHeight); } - - // ═══════════════════════════════════════════════════════════ - // Footer area (ornate status bar) - // ═══════════════════════════════════════════════════════════ - const footerY = h - bottomMargin + 3; + + paintButtons($); + + // Status between buttons (commit count + next-poll countdown). const sinceLastFetch = Date.now() - lastFetch; const nextPoll = Math.max(0, Math.ceil((POLL_INTERVAL - sinceLastFetch) / 1000)); - - // Auto-scroll delay progress bar + const countText = hasMoreCommits ? `${commits.length}+ · ${nextPoll}s` : `${commits.length} · ${nextPoll}s`; + const countX = Math.floor(w / 2 - text.width(countText, FONT) / 2); + ink(100, 105, 130).write( + countText, + { x: countX, y: h - bottomMargin + 7 }, + false, + undefined, + false, + FONT, + ); + const sinceLoad = Date.now() - loadTime; - const delayProgress = loadTime > 0 ? Math.min(1, sinceLoad / autoScrollDelay) : 0; - const waitingToScroll = loadTime > 0 && !autoScroll && delayProgress < 1; - - // Left section: playback state - if (waitingToScroll) { - // Progress bar during delay - const barWidth = 24; - const barX = 4; - ink(30, 32, 40).box(barX, footerY, barWidth, 7); - ink(100, 200, 150).box(barX, footerY, Math.floor(barWidth * delayProgress), 7); - ink(60, 65, 80).box(barX, footerY, barWidth, 7, "outline"); - } else { - // Auto-scroll indicator with icon - const playIcon = autoScroll ? "►" : "║║"; - const playColor = autoScroll ? COLORS.additions : [100, 100, 120]; - ink(...playColor).write(playIcon, { x: 4, y: footerY }, false, undefined, false, FONT); - } - - // Poll countdown with subtle animation - const pollAlpha = 150 + sin(pulsePhase + nextPoll * 0.2) * 50; - ink(80, 85, 105, pollAlpha).write(`${nextPoll}s`, { x: waitingToScroll ? 32 : 20, y: footerY }, false, undefined, false, FONT); - - // Center: view toggle hint - const toggleHint = showDetailedView ? "[d]etail" : "[d]etail"; - const hintX = floor(w / 2) - floor(text.width(toggleHint, FONT) / 2); - ink(60, 65, 80).write(toggleHint, { x: hintX, y: footerY }, false, undefined, false, FONT); - - // Right section: commit count - const countText = hasMoreCommits ? `${commits.length}+` : `${commits.length}`; - const countX = w - text.width(countText, FONT) - 4; - ink(100, 105, 130).write(countText, { x: countX, y: footerY }, false, undefined, false, FONT); - - // Stats cache indicator - const cacheText = `${statsCache.size}★`; - ink(60, 80, 60).write(cacheText, { x: countX - text.width(cacheText + " ", FONT), y: footerY }, false, undefined, false, FONT); - - // Keep painting for animations - if (autoScroll || waitingToScroll || statsFetching.size > 0 || !showDetailedView) needsPaint(); + const waitingToScroll = + loadTime > 0 && !autoScroll && sinceLoad < autoScrollDelay; + + if (autoScroll || waitingToScroll) needsPaint(); +} + +function paintButtons($) { + if (!tangledBtn) return; + tangledBtn.paint($, LINK_SCHEME, LINK_HOVER); + detailBtn.paint($, BTN_SCHEME, BTN_HOVER); + playBtn.paint($, PLAY_SCHEME, PLAY_HOVER); + refreshBtn.paint($, BTN_SCHEME, BTN_HOVER); } -function act({ event: e, screen, store, jump }) { - const { height: h } = screen; - - // 📜 Scrolling - any manual scroll disables auto-scroll +function act({ event: e, screen, jump, ui, net }) { + if (!tangledBtn) buildButtons({ ui, screen }); + + // Buttons. + tangledBtn.act(e, () => { + jump(`out:${TANGLED_REPO_URL}`); + }); + + detailBtn.act(e, () => { + showDetailedView = !showDetailedView; + needsLayout = true; + }); + + playBtn.act(e, () => { + autoScroll = !autoScroll; + if (autoScroll) scroll = 0; + }); + + refreshBtn.act(e, () => { + fetchCommits(1, false); + }); + + // Scroll via drag — disables auto-scroll. if (e.is("draw")) { autoScroll = false; - scroll -= e.delta.y; // Invert for natural scroll direction + scroll -= e.delta.y; boundScroll(); loadMoreIfNeeded(); } - - // Keyboard controls - if (e.is("keyboard:down:arrowup") || e.is("keyboard:down:k")) { - autoScroll = false; - scroll -= rowHeight * 4; - boundScroll(); - } - - if (e.is("keyboard:down:arrowdown") || e.is("keyboard:down:j")) { - autoScroll = false; - scroll += rowHeight * 4; - boundScroll(); - loadMoreIfNeeded(); - } - - if (e.is("keyboard:down:home")) { - autoScroll = false; - scroll = 0; - } - - if (e.is("keyboard:down:end")) { - autoScroll = false; - scroll = max(0, totalScrollHeight - chatHeight + 5); - loadMoreIfNeeded(); - } - - // Page up/down - if (e.is("keyboard:down:pageup")) { - autoScroll = false; - scroll -= chatHeight * 0.8; - boundScroll(); - } - - if (e.is("keyboard:down:pagedown")) { - autoScroll = false; - scroll += chatHeight * 0.8; - boundScroll(); - loadMoreIfNeeded(); - } - - // Toggle auto-scroll with Space - if (e.is("keyboard:down: ")) { - autoScroll = !autoScroll; - if (autoScroll) scroll = 0; // Reset to top when enabling - } - - // Toggle detailed view with D - if (e.is("keyboard:down:d")) { - showDetailedView = !showDetailedView; - needsLayout = true; - } - - // Refresh on R - if (e.is("keyboard:down:r")) { - statsCache.clear(); - fetchCommits(1, false); - } - - // GitHub link click - if (e.is("touch") && githubLinkBox) { - const { x, y } = e; - if (x >= githubLinkBox.x && x <= githubLinkBox.x + githubLinkBox.w && - y >= githubLinkBox.y && y <= githubLinkBox.y + githubLinkBox.h) { - jump(`out:https://github.com/${REPO}`); - } - } - // Back to prompt - if (e.is("keyboard:down:escape")) { - jump("prompt"); + if (e.is("reframed")) { + syncButtons({ screen }); } + + // Back to prompt (standard AC convention). + if (e.is("keyboard:down:escape") || e.is("keyboard:down:`")) jump("prompt"); + if (e.is("keyboard:down:backspace")) jump("prompt"); } -function sim({ store }) { - // Decay the new-commit flash +function sim() { if (hue > 0) hue = Math.max(0, hue - 0.5); - - // Auto-start scrolling after delay + const sinceLoad = Date.now() - loadTime; if (loadTime > 0 && !autoScroll && sinceLoad >= autoScrollDelay && scroll === 0) { autoScroll = true; } - - // Auto-scroll + if (autoScroll && totalScrollHeight > chatHeight) { scroll += autoScrollSpeed; boundScroll(); - - // Loop back to top when reaching end - if (scroll >= totalScrollHeight - chatHeight) { - scroll = 0; - } - + if (scroll >= totalScrollHeight - chatHeight) scroll = 0; loadMoreIfNeeded(); } } function leave() { - // Clean up polling if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } - // Clear caches - statsCache.clear(); - statsFetching.clear(); } function meta() { return { title: "Commits", - desc: "╔═══════════════════════════════════════╗\n║ Live GitHub commit feed with stats ║\n║ +additions -deletions • files changed ║\n╚═══════════════════════════════════════╝", + desc: "Live Tangled commit feed for aesthetic.computer/core.", }; } -- 2.51.2