fuzzyworld-tools / commit
Show Claude Code token usage per commit; npm run dev preview; My AI Use page
README.md | 17 ++- package.json | 1 + server/content/_how.md | 2 +- server/content/my-ai-use.md | 44 ++++++++ server/content/tower.png | Bin 0 -> 223373 bytes server/content/why-i-left-gitlab.md | 22 +--- server/gen.py | 207 +++++++++++++++++++++++++++++++++--- server/preview.sh | 31 ++++++ server/site/style.css | 27 +++++ 9 files changed, 316 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 33615cd..fb41bb8 100644 --- a/README.md +++ b/README.md @@ -128,20 +128,35 @@ Every repo uses the shared hook via `core.hooksPath=~/fw/tools/server/hooks`. `p | `/<name>/log/` | the branch's commits (up to 2000) | | `/<name>/commit/<sha>.html` | one commit with its diff (truncated at 300 KB); a page for every commit on every branch, up to 2000 per repo | | `/<name>/refs/` | branches and tags | +| `/<name>/usage/` | Claude Code token usage per commit and per session, read from `Usage:` trailers in commit messages (only for repos that have them; see below) | | `/<name>/branch/<branch>/...` | the same tree/blob/history/blame/log pages for another branch; `/` in a branch name is written `~` | | `/<name>/ci/`, `/<name>/ci/<sha>.html` | CI pipelines (newest 100) and one pipeline with its job logs | | `/<name>/ci/badge.svg` | status of the latest pipeline on the default branch | | `/ci/` | CI dashboard: running pipelines (each job's state and time), the queue, the last 50 results, for public repos | | `/<name>/files.json`, `/<name>/objects/<sha>` | for VS Code in the browser: every file's blob SHA and size, and each file's contents (≤ 1 MB) | -| `/why/`, `/principles/`, `/contribute/` | about the site. All are files in `server/content/` (Markdown, or an HTML fragment with a leading `<!-- title: … -->`), rendered by `fw content` into the common page layout. A file starting with `_` (the `/how/` draft) is not built. `fw content` also writes `404.html` | +| `/why-i-left-gitlab/`, `/my-ai-use/`, `/contribute/` | about the site (`/why/` and `/principles/` redirect to the first). All are files in `server/content/` (Markdown, or an HTML fragment with a leading `<!-- title: … -->`), rendered by `fw content` into the common page layout. An image in that folder that a page mentions (``) is copied next to the page. A file starting with `_` (the `/how/` draft) is not built. `fw content` also writes `404.html` | **Branches:** the default branch, and every branch **not yet merged** into it, get browse pages. A merged branch adds nothing new to read and would duplicate every page, so the branches page lists it with a link to its last commit. Every branch and tag can be cloned. Author email addresses are not shown. +**Usage trailers:** a commit message can carry a Claude Code session's running token tally as git trailers: +`FW-Tokens-In`, `FW-Tokens-Out`, `FW-Tokens-Cached` (exact integers), plus `FW-Session` (the session id, for +provenance only) and `FW-Rtk-Saved`/`FW-Rtk-Cmds` (rtk's lifetime savings, not read by the site). Older commits +have one rounded line instead, `Usage: 10.6K in / 101.3K out / 5.1M cached (session tally)`, which is still read. +A commit's cost is the step from the previous tally (a tally that drops starts a new session). The log shows a +bar of output tokens per commit, highlighted at 2× the median or more; each commit page shows its step and the +session tally; `/<name>/usage/` ranks commits and totals sessions. Steps between old-style lines are only known +to within the rounding (about two tenths of K/M/G), so small ones show as ≈0; commits without a trailer show no bar. + **License:** the summary page names the repo's license, read from a root `LICENSE`/`LICENCE`/`COPYING` file (MIT, Apache-2.0, GPL, LGPL, AGPL, MPL-2.0, BSD, ISC, Unlicense, CC0), else from `package.json`. +**Preview locally:** `npm run dev` builds this repo's pages into a temp directory and serves them at +http://localhost:8000/. Pass other working copies after `--` (`npm run dev -- ~/code/explodorama`), and set +`PORT` to change the port. It uses each repo's committed state, and `server/site/*.css` edits show on reload; +a change to `gen.py` needs a restart. + ## Security The site never executes anything from a repo: diff --git a/package.json b/package.json index 7f5c732..e92719b 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "Tooling for code.fuzzyworld.net and apps.fuzzyworld.net: fw command, deploy, vite plugin.", "type": "module", "scripts": { + "dev": "server/preview.sh", "update-server": "server/install.sh", "update-server-and-republish": "server/install.sh --publish", "republish-all": "bin/fw publish-all", diff --git a/server/content/_how.md b/server/content/_how.md index ec7d055..568468d 100644 --- a/server/content/_how.md +++ b/server/content/_how.md @@ -52,6 +52,6 @@ Yes. Everything is static, it costs nothing extra on hosting I already paid for, Things made with AI should be shared freely with the world. The code and apps here were made with AI, so they are MIT-licensed: use them, change them, share them. -My conversations with the AI are different. My words and my way of working are mine. I publish a conversation only when I choose to, I can take that permission back, and nobody may copy my words or my style of work without my express permission. +My conversations with the AI are different. My words and my way of working are mine. I publish a conversation only when I choose to. [← Why I left GitLab](/why-i-left-gitlab/) diff --git a/server/content/my-ai-use.md b/server/content/my-ai-use.md new file mode 100644 index 0000000..7788c16 --- /dev/null +++ b/server/content/my-ai-use.md @@ -0,0 +1,44 @@ +title: My AI Use + +# My AI Use + +I don't use generative AI. I don't create images, I don't ask AI to write poems or make videos. I ask AI yes-or-no questions and I use it to code. + +I've been "agentic coding" since March of this year, when I "vibe coded" (parlance of the time) a web app that plays with cats: [Cat Playground](https://code.fuzzyworld.net/cat-playground/). The app was made by talking with ChatGPT and asking it to implement various changes. I started out explaining what I would do to create the app, and ChatGPT implemented it for me. It was an interesting experience and we quickly got a prototype up and working. ChatGPT made some curious choices, like butterflies that flapped the wrong way, but it did a wonderful job, as long as I was patient and willing to explain to it in verbose terms. + +Some weekends, I'd wake up, consider working on a project, and actually dread needing to explain things to an AI. Those days, I picked up the PS5 controller and became a cowboy (RDR2). But on the "good" days, I was able to chat with an agent and it coded up a web app for me in a matter of hours. + +## Recent AI usage + +My most recent project (over vacation) was to develop a turn-based, rogue-like Bomberman clone, and I got that done before coming back to work. On Monday, October 26, I opened GitLab.com on my work computer, browsed to my repos, checked out the git history, was looking forward to sharing this game, when GitLab started giving me "rate limit exceeded" errors. Suddenly GitLab.com is erroring out. The GitLab Pages site is still working, so I can show off my app, but it would be nice to have the code and the readme with it. No such luck. Apparently GitLab recently started enforcing rather harsh limits on basic website users. Well, that was the end for me. I'm leaving GitLab. + +I'm doing it by vibe coding a replacement that runs on my shared Dreamhost server. That's this website. This whole site is static and deploys from my laptop, and it holds the source code for all of my apps, and it hosts the web apps built from them. + +So, what are my views on AI? **Skip generative AI and make the art yourself or out-source it to a human. Aside from that, AI is a microcosm of humanity (however it was created), and anything we get from that microcosm belongs to humanity. Everything that I make with AI is open-sourced and belongs to humanity.** + +And let me give you a very specific example of that: Tower. + +## Tower + +I started writing an alien abduction story, and part of this story is a gigantic space station called Tower which looks like a can of Pringles floating in space. So, being who I am, of course vibe coded a web app that simulates the space station and allows me to fly around it. AI did the calculations to figure out the limits of the structure, the speed of the spin, the energy requirements, etc., and created a physically accurate simulation of something that until now just existed in my mind. + + + +[You can also fly around this fictional world, just click here!](https://apps.fuzzyworld.net/tower/) + +And you can see the code for Tower the web app here: [https://code.fuzzyworld.net/tower/](https://code.fuzzyworld.net/tower/) + +I vibe coded the web app that simulates a fictional world. That app belongs to everyone. But my words about this fictional world will cost a few bucks, hopefully. :) + +## My projects + +Just a few of the projects I got into a reasonable shape: + +1. [engye](https://code.fuzzyworld.net/engye/) - local file-sharing app with QR-code pairing. Share files locally without accounts. +2. [Nordic Skating](https://code.fuzzyworld.net/nordic-skating/) - endless skate across thin black ice that pings. This was done by AI making tools to analyze a National Geographic video of a skater, and reading sea ice thickness research papers to figure out and confirm the physics of the acoustics of these frozen lakes. +3. AdventOfCode coding harness - a harness that automatically pulls down the text and tests of a given AoC page and sets up my template. I had this setup for a while now, but it was hand-coded. I used AI to setup this standalone solution and I'm working on improving it to the point that it can be released. +4. [Tower](https://code.fuzzyworld.net/tower/) - a 3D model of a fictional space station. I'm writing the story and using the simulation to look around my fictional world, seeing what sunrise looks like if we use 80 billion tiny mirrors to illuminate a tube with the surface area of Wisconsin. +5. [Explodorama](https://code.fuzzyworld.net/explodorama/) - a turn-based, rogue-like Bomberman clone. We've got power-ups, charges, coins, hearts. We have demos of each single move, enemy, power-up, way to die, etc., all created on the fly and rendered on an old TV set. We've got automated simulation of games to compare various power-ups (teleportation is a game-changer, who knew?!), almost a thousand tests, automated screenshot creation. We warn real hard against player doing something stupid, but we still let you walk into fire. We use the CC0 [Kenney.nl platformer pack](https://kenney.nl/assets/new-platformer-pack) for most art and sound design. We've got rockets, nukes, acid bombs that can cause a cave-in, decoys and proximity mines, even stores in between levels. Play it or modify it with your own art, your own rules, your own AI. +6. [Auto Print](https://code.fuzzyworld.net/auto-print/) - I've got 3 finished manuscripts and a 4th half-way done, all of them are written in Markdown, and I vibe coded a simple way to create digital books from my manuscripts. I have the flexibility to write how I write, the AI-written conversion tool handles whatever you want to throw at it and produces PDF, DOCX, EPUB, and KPF files, so I can share my latest "masterpiece" with anyone in just a minute. The repo is designed to make it easy to produce ebooks from your markdown and it's open source. +7. [This site](https://code.fuzzyworld.net/fuzzyworld-tools/) - I'm happy with the flexibility of this site and the ease with which I was able to remove my dependency on GitLab. Working on making all of this work open source and super-simple to use. + diff --git a/server/content/tower.png b/server/content/tower.png new file mode 100644 index 0000000..4d13815 Binary files /dev/null and b/server/content/tower.png differ diff --git a/server/content/why-i-left-gitlab.md b/server/content/why-i-left-gitlab.md index d58d6cb..0ec4595 100644 --- a/server/content/why-i-left-gitlab.md +++ b/server/content/why-i-left-gitlab.md @@ -2,26 +2,12 @@ title: Why I left GitLab # Why I left GitLab -On Monday, October 26, 2026, I came back to work after a week's vacation to Friday Harbor. While on vacation I did what I like to do for fun and made a turn-based rogue-like Bomberman clone. -So back at work, I was thinking of sharing it with some coworkers, -but very quickly GitLab showed me a "Rate limit exceeded" error. And I rapidly learned that GitLab enacted -some rate-limiting policies recently that blocked repo access at work, and, very quickly, even -access to GitLab itself! +On Monday, October 26, 2026, I came back to work after a week's vacation to Friday Harbor. While on vacation I did what I like to do for fun and made [a turn-based rogue-like Bomberman clone](https://apps.fuzzyworld.net/explodorama/) ([src](https://code.fuzzyworld.net/explodorama/)). So back at work, I was thinking of sharing it with some coworkers, but very quickly GitLab showed me a "Rate limit exceeded" error. And I rapidly learned that GitLab enacted some rate-limiting policies recently that blocked repo access at work, and, very quickly, even access to GitLab itself! -So yeah, I couldn't look at my personal code at work, and that pissed me off, and now all of my code and -projects are hosted on Dreamhost, where I already keep a blog and a lightweight webpage. +So yeah, I couldn't look at my personal code at work, and that pissed me off, and now all of my code and projects are hosted on Dreamhost, where I already keep a blog and a lightweight webpage. The move was relatively painless, since I was just using GitLab for repo storage and GitLab Pages, so there was very little that had to be replicated. -- code: <https://code.fuzzyworld.net/> -- apps: <https://apps.fuzzyworld.net/> +Now my code lives at <https://code.fuzzyworld.net/>, and the apps are automatically deployed to <https://apps.fuzzyworld.net/>. No rate limiting, no sign-in, no "modern" limits. This is a static website! What more do I really need? -I'm currently trying to formalize the process of the move from GitLab to Dreamhost, and eventually my -learnings are going to be at /how/ on this website. - -## Principles - -Things made with AI should be shared freely with the world. The code and apps here were made with -AI, so they are MIT-licensed: use them, change them, share them. - -My conversations with the AI are different. My words and my way of working are mine. I publish a conversation only when I choose to. +I'm currently trying to formalize the process of the move from GitLab to Dreamhost. Eventually my learnings are going to be linked here. diff --git a/server/gen.py b/server/gen.py index 7ff72ed..e64ef56 100755 --- a/server/gen.py +++ b/server/gen.py @@ -41,7 +41,7 @@ BASE_URL = "https://code.fuzzyworld.net" APPS_URL = "https://apps.fuzzyworld.net" # Bump when page templates change, so every repo gets a full rebuild once. -GEN_VERSION = 8 +GEN_VERSION = 9 COMMIT_LIMIT = 2000 # commit pages per repo, across all branches; older commits: clone HISTORY_LIMIT = 200 # commits listed on a file's history page @@ -178,6 +178,7 @@ def page(title, body, crumbs=None, view=None, site="code", refresh=None, section f'<a href="{view.url("log")}/">Log</a>' f'<a href="{url(view.name, "refs")}/">Branches & tags</a>' + (f'<a href="{url(view.name, "ci")}/">CI</a>' if ci_pipelines(view.name) else "") + + (f'<a href="{url(view.name, "usage")}/">Usage</a>' if repo_usage(view.name)["costs"] else "") + "</nav>") crumb_html = "" if crumbs: @@ -553,6 +554,10 @@ def build_content(): m = re.match(r"<!-- title: (.*?) -->\n", text) title, body = (m.group(1), text[m.end():]) if m else (stem, text) write(os.path.join(SITE, stem, "index.html"), page(title, body, section=stem)) + # Images beside the page that it mentions (``) are copied next to its index.html. + for img in sorted(os.listdir(CONTENT)): + if img.lower().endswith(SHOT_EXTENSIONS) and img in text: + shutil.copyfile(os.path.join(CONTENT, img), os.path.join(SITE, stem, img)) write(os.path.join(SITE, "404.html"), page("Not found", ( "<h1>Not found</h1><p>There's nothing at this address. Private repositories aren't listed here, " 'and only the default branch can be browsed.</p><p><a href="/">All repositories</a></p>'), section="-")) @@ -796,18 +801,176 @@ def file_histories(repo, ref, paths): return hist -def commits_table(name, commits, commit_set=None): +# Commits written with Claude Code carry the session's running token tally as git trailers: +# FW-Tokens-In: 10638 +# FW-Tokens-Out: 101300 +# FW-Tokens-Cached: 5110043 +# FW-Session: <session id> (provenance only: a resumed session may change it while the tally goes on) +# Older commits have one rounded line instead: "Usage: 10.6K in / 101.3K out / 5.1M cached (session tally)". +# A commit's own cost is the step from the previous commit's tally. The tally falls when a new +# session starts, and that commit's cost is its whole tally. +USAGE_KEYS = ("FW-Tokens-In", "FW-Tokens-Out", "FW-Tokens-Cached") +USAGE_RE = re.compile(r"^Usage:\s*([\d.]+)([KMG]?) in / ([\d.]+)([KMG]?) out / ([\d.]+)([KMG]?) cached", re.M) +USAGE_UNITS = {"": 1, "K": 10**3, "M": 10**6, "G": 10**9} +USAGE_HOT = 2 # a commit with this many times the median output is highlighted + + +def parse_usage(message): + """([in, out, cached], [slack, ...]) from a message's usage trailers, or None. + + Trailer counts are exact. The old Usage: line is rounded to a tenth of its unit, so each count + is only known to within half a tenth: the slack. + """ + counts = [re.search(rf"^{key}:\s*(\d+)\s*$", message, re.M) for key in USAGE_KEYS] + if all(counts): + return [float(m.group(1)) for m in counts], [0, 0, 0] + m = USAGE_RE.search(message) + if not m: + return None + units = [USAGE_UNITS[m.group(i + 1)] for i in (1, 3, 5)] + return [float(m.group(i)) * u for i, u in zip((1, 3, 5), units)], [u / 20 for u in units] + + +def fmt_tokens(n): + """10638 -> '10.6K': the units the trailer is written in.""" + for unit, suffix in ((10**9, "G"), (10**6, "M"), (10**3, "K")): + if n >= unit: + return f"{n / unit:.1f}{suffix}" + return str(round(n)) + + +def approx_tokens(n, slack): + return "≈0" if n <= slack else ("~" if slack else "") + fmt_tokens(n) + + +def usage_costs(repo, ref): + """{sha: {"row", "tally", "delta", "slack", "session", "new"}} for each commit on `ref`'s first-parent + chain that has a Usage trailer, oldest first. `row` is the commit as commit_list returns it.""" + raw = git_text(repo, "log", "--first-parent", "--reverse", "--date=short", + f"--format={COMMIT_FORMAT}%x1f%B%x1e", ref, check=False) + costs, prev, session, total = {}, None, 0, 0 + for chunk in raw.split("\x1e"): + fields = chunk.strip("\n").split("\x1f", 5) + if len(fields) != 6: + continue + total += 1 + parsed = parse_usage(fields[5]) + if not parsed: + continue + tally, slack = parsed + new = prev is None or tally[1] < prev[0][1] or tally[2] < prev[0][2] + if new: + session += 1 + delta, delta_slack = tally, slack + else: + delta = [t - p for t, p in zip(tally, prev[0])] + delta_slack = [s + p for s, p in zip(slack, prev[1])] + costs[fields[0]] = {"row": fields[:5], "tally": tally, "delta": delta, "slack": delta_slack, + "session": session, "new": new} + prev = (tally, slack) + return costs, total + + +_usage_cache = {} + + +def repo_usage(name): + """{"costs", "commits", "median", "max"} for a repo's default branch (output tokens per commit).""" + if name not in _usage_cache: + costs, total = usage_costs(os.path.join(GIT_ROOT, name + ".git"), "HEAD") + outs = sorted(c["delta"][1] for c in costs.values()) + _usage_cache[name] = {"costs": costs, "commits": total, "median": outs[len(outs) // 2] if outs else 0, + "max": outs[-1] if outs else 0} + return _usage_cache[name] + + +def usage_cell(usage, sha): + c = usage["costs"].get(sha) + if not c: + return '<td class="cost"></td>' + out, slack = c["delta"][1], c["slack"][1] + median = usage["median"] + real = out > slack + hot = real and median and out >= USAGE_HOT * median + title = (f"{approx_tokens(out, slack)} output tokens, {approx_tokens(c['delta'][2], c['slack'][2])} cached reads" + + (f" ({out / median:.1f}× the median commit)" if real and median else "") + + (" · first commit of a session: counts everything since it began" if c["new"] else "")) + width = 100 * out / usage["max"] if usage["max"] else 0 + return (f'<td class="cost" title="{esc(title)}"><span class="track"><span class="bar{" hot" if hot else ""}" ' + f'style="width:{width:.1f}%"></span></span>{approx_tokens(out, slack)}</td>') + + +def usage_panel(name, sha): + """Usage line for a commit page. Only facts fixed by the commit's own history: no rank, so the page never goes stale.""" + c = repo_usage(name)["costs"].get(sha) + if not c: + return "" + d, s, t = c["delta"], c["slack"], c["tally"] + return ('<p class="meta usage">Claude Code usage: this commit ' + f'{approx_tokens(d[1], s[1])} out / {approx_tokens(d[2], s[2])} cached' + + (" (first commit of its session: everything since it began)" if c["new"] else "") + + f' · session {c["session"]} tally {fmt_tokens(t[0])} in / {fmt_tokens(t[1])} out / {fmt_tokens(t[2])} cached' + f' · <a href="{url(name, "usage")}/">usage by commit and session</a></p>') + + +def commits_table(name, commits, commit_set=None, usage=None): def sha_cell(h, s): if commit_set is not None and h not in commit_set: return f"<code>{esc(s)}</code>" return f'<a href="{url(name, "commit", h)}.html"><code>{esc(s)}</code></a>' ci = ci_pipelines(name) rows = "".join(f'<tr><td class="date">{esc(d)}</td><td>{sha_cell(h, s)}</td>' - f'<td>{ci_mark(name, h, ci.get(h))}{esc(subj)}</td><td class="author">{esc(a)}</td></tr>' + f'<td>{ci_mark(name, h, ci.get(h))}{esc(subj)}</td><td class="author">{esc(a)}</td>' + f'{usage_cell(usage, h) if usage else ""}</tr>' for h, s, a, d, subj in commits) return f'<table class="log">{rows}</table>' +def build_usage_page(view, commit_set): + """/<repo>/usage/: commits by output tokens, and a table of sessions. Removed when no commit has a trailer.""" + name = view.name + usage = repo_usage(name) + costs = usage["costs"] + path = view.path("usage", "index.html") + if not costs: + remove(path) + return + sessions = {} + for c in costs.values(): + sessions.setdefault(c["session"], []).append(c) + total_out = sum(c["delta"][1] for c in costs.values()) or 1 + total_cached = sum(c["delta"][2] for c in costs.values()) + session_rows = "".join( + f'<tr><td class="num">{n}</td><td class="date">{esc(cs[0]["row"][3])}' + + (f' – {esc(cs[-1]["row"][3])}' if cs[-1]["row"][3] != cs[0]["row"][3] else "") + "</td>" + f'<td class="num">{len(cs)}</td><td class="num">{fmt_tokens(sum(c["delta"][1] for c in cs))}</td>' + f'<td class="num">{fmt_tokens(sum(c["delta"][2] for c in cs))}</td>' + f'<td class="num">{100 * sum(c["delta"][1] for c in cs) / total_out:.0f}%</td></tr>' + for n, cs in sessions.items()) + ranked = sorted(costs.values(), key=lambda c: -c["delta"][1]) + body = (f'<h1>Claude Code usage</h1><p class="meta">From the <code>Usage:</code> trailers in commit messages · ' + f'{len(costs)} of {usage["commits"]} commits have one · {len(sessions)} sessions · ' + f'~{fmt_tokens(total_out)} output tokens, ~{fmt_tokens(total_cached)} cached reads</p>' + f'<h2>Commits by output tokens</h2>' + f'{commits_table(name, [c["row"] for c in ranked[:30]], commit_set, usage)}' + f'<h2>Sessions</h2><table class="log"><tr><th>#</th><th>Dates</th><th>Commits</th><th>Output</th>' + f'<th>Cached</th><th>Share of output</th></tr>{session_rows}</table>' + '<h2>How this is measured</h2><ul>' + '<li>A trailer is the running token tally of one Claude Code session. A commit\'s cost is the step ' + 'from the previous commit\'s tally. A tally that drops starts a new session, and its first commit ' + 'gets the whole tally.</li>' + '<li>Bars and rankings use output tokens. Cached reads grow with how long the context is and how many ' + 'turns ran, so they track session length more than the work; input tokens are negligible. These are ' + 'tokens, not dollars: the price depends on the model.</li>' + '<li>Tallies are rounded to a tenth of K, M or G, so a step is only known to within about two ' + 'such tenths. Steps at or under that show as ≈0.</li>' + '<li>A commit with no trailer shows no bar, and the work before it is counted in the next commit ' + 'that has one. The cost is of everything done since the previous trailer, including dead ends, ' + 'not only the code in the commit.</li>' + f'<li>Highlighted bars: at least {USAGE_HOT}× the median commit.</li></ul>') + write(path, page(f"{name} usage", body, [f'<a href="{url(name)}/">{esc(name)}</a>', "usage"], view=view)) + + def build_history_page(view, path, commits, commit_set): body = (f'{branch_note(view)}<h1>{esc(posixpath.basename(path))}</h1>' f'<p class="meta">Commits that changed this file, newest first · ' @@ -840,9 +1003,9 @@ def write_short_sha_alias(out, view, alias, full): write(os.path.join(out, "commit", alias + ".html"), html_out) -def build_commit_page(repo, out, view, sha): +def build_commit_page(repo, out, view, sha, force=False): path = os.path.join(out, "commit", sha + ".html") - if os.path.exists(path): + if os.path.exists(path) and not force: return # commits never change name = view.name head = git_text(repo, "show", "-s", "--format=%H%n%an <%ae>%n%ad%n%P%n%B", "--date=iso", sha) @@ -867,6 +1030,7 @@ def build_commit_page(repo, out, view, sha): body = (f"<h1>{esc(msg[0] if msg else full)}</h1>" f'<p class="meta"><code>{full}</code> · {esc(author)} · {esc(date)}' f'{" · parents " + parent_links if parent_links else ""}</p>' + f'{usage_panel(name, full)}' f'<pre class="message">{esc(chr(10).join(msg).strip())}</pre>' f'<pre class="diff">{chr(10).join(lines)}</pre>' + ("<p>Diff truncated. Clone the repository to see all of it.</p>" if truncated else "")) @@ -1073,8 +1237,12 @@ def build_view(repo, view, vstate, commit_set): for d in dirs: build_tree_page(view, d, files, dirs) log_title = f"Last {len(commits)} commits" + ("" if view.is_default else f" on {esc(view.branch)}") + usage = repo_usage(view.name) + usage_note = (f'<p class="meta">Bars: Claude Code output tokens per commit, highlighted at {USAGE_HOT}× the median or more · ' + f'<a href="{url(view.name, "usage")}/">details</a></p>' if usage["costs"] else "") write(view.path("log", "index.html"), - page(f"{view.name} log", f"{branch_note(view)}<h1>{log_title}</h1>{commits_table(view.name, commits, commit_set)}", view=view)) + page(f"{view.name} log", f"{branch_note(view)}<h1>{log_title}</h1>{usage_note}" + f"{commits_table(view.name, commits, commit_set, usage if usage['costs'] else None)}", view=view)) print(f"{view.name} [{view.branch}]: {len(files)} files, {written} file pages written, {len(commits)} commits") return {"head": head, "files": {p: e[1] for p, e in files.items()}, "dirs": sorted(dirs), "hist": new_hist, "_files": files, "_ctx": ctx, "_commits": commits} @@ -1094,7 +1262,8 @@ def build_repo(name): state = json.load(f) except (FileNotFoundError, ValueError): state = {} - if state.get("version") != GEN_VERSION: + stale = state.get("version") != GEN_VERSION + if stale: state = {} desc = repo_config(repo, "fuzzyworld.description") @@ -1117,7 +1286,7 @@ def build_repo(name): for a in set(state.get("aliases", [])) - set(aliases): remove(os.path.join(out, "commit", a + ".html")) for h in commit_shas: - build_commit_page(repo, out, default_view, h) + build_commit_page(repo, out, default_view, h, force=stale) for alias, full in aliases.items(): write_short_sha_alias(out, default_view, alias, full) @@ -1154,6 +1323,7 @@ def build_repo(name): d = built.get(default_branch, {}) if not empty: build_refs_page(repo, default_view, views, commit_set) + build_usage_page(default_view, commit_set) build_ci_pages(default_view) build_summary(repo, default_view, desc, d.get("_files", {}), d.get("_ctx"), d.get("_commits", []), empty, commit_set) @@ -1186,15 +1356,15 @@ def build_index(): f'<span class="meta">updated {esc(last or "never (empty)")}{app}</span>{desc}</li>') body = (f"<h1>Pavel's code</h1>" f"<p>Browse, or clone with <code>git clone {BASE_URL}/<repo>.git</code>. No account or sign-in needed. " - f'Small web apps are at <a href="{APPS_URL}/">apps.fuzzyworld.net</a>. ' - f'<a href="/why-i-left-gitlab/">Why I left GitLab</a></p>' + f'Small web apps are at <a href="{APPS_URL}/">apps.fuzzyworld.net</a>. <br /> <br />' + f'<a href="/why-i-left-gitlab/">Why aren\'t those GitHub/GitLab/Codeberg links? (Why I left GitLab)</a></p>' f'<ul class="repos">{"".join(items)}</ul>') write(os.path.join(SITE, "index.html"), page("Repositories", body)) print(f"index: {len(rows)} public repos") def build_apps_index(): - """apps.fuzzyworld.net/index.html from each deployed app's .fuzzyworld.json, newest deploy first.""" + """apps.fuzzyworld.net/index.html from each deployed app's .fuzzyworld.json, newest commit first.""" apps = [] for entry in sorted(os.listdir(APPS)): meta_path = os.path.join(APPS, entry, ".fuzzyworld.json") @@ -1209,10 +1379,17 @@ def build_apps_index(): "description": meta.get("description", ""), "repo": meta.get("repo", entry), "commit": meta.get("commit", ""), "deployed": meta.get("deployed", "")}) for a in apps: - try: - a["time"] = datetime.datetime.fromisoformat(a["deployed"].replace("Z", "+00:00")).timestamp() - except ValueError: - a["time"] = 0 + # Use commit date from the repo instead of deploy timestamp for consistent sorting + repo = os.path.join(GIT_ROOT, a["repo"] + ".git") + if a["repo"] and os.path.isdir(repo): + last = git_text(repo, "log", "-1", "--format=%ct", "HEAD", check=False).strip() + a["time"] = int(last) if last.isdigit() else 0 + else: + # Fallback to deploy timestamp if repo is not accessible + try: + a["time"] = datetime.datetime.fromisoformat(a["deployed"].replace("Z", "+00:00")).timestamp() + except ValueError: + a["time"] = 0 # Apps hosted elsewhere (e.g. engye on Cloudflare Pages), listed in server/site-apps/extra.json. # They have no deploy record, so their repo's last commit stands in for the update time. extra = os.path.join(os.path.dirname(os.path.abspath(__file__)), "site-apps", "extra.json") diff --git a/server/preview.sh b/server/preview.sh new file mode 100755 index 0000000..962a846 --- /dev/null +++ b/server/preview.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Preview code.fuzzyworld.net locally, built from working copies of repos: +# npm run dev this repo +# npm run dev -- ~/code/explodorama other repos (any number) +# PORT=9000 npm run dev -- ... +# Each run clones the repos afresh (committed state only, not uncommitted edits), regenerates the +# pages with gen.py, and serves them. The stylesheets are symlinks, so CSS edits show on reload; +# changes to gen.py need a restart. +set -euo pipefail +cd "$(dirname "$0")/.." +PORT=${PORT:-8000} +W=${TMPDIR:-/tmp}/fw-preview +rm -rf "$W" +mkdir -p "$W"/{git,site,apps,state,ci,queue} +[ $# -gt 0 ] || set -- . +export FW_GIT_ROOT="$W/git" FW_CODE_SITE="$W/site" FW_APPS_SITE="$W/apps" FW_STATE="$W/state" FW_CI="$W/ci" FW_QUEUE="$W/queue" +names=() +for r in "$@"; do + top=$(git -C "$r" rev-parse --show-toplevel) + name=$(basename "$top") + git clone -q --bare "$top" "$FW_GIT_ROOT/$name.git" + git -C "$FW_GIT_ROOT/$name.git" config fuzzyworld.visibility public + python3 server/gen.py repo "$name" + names+=("$name") +done +python3 server/gen.py index +for f in _shared.css _icons.css style.css favicon.ico; do + ln -sf "$PWD/server/site/$f" "$W/site/$f" +done +echo "Serving http://localhost:$PORT/ ($(IFS=,; echo "${names[*]}"))" +exec python3 -m http.server "$PORT" --directory "$W/site" diff --git a/server/site/style.css b/server/site/style.css index 37cc115..20891d8 100644 --- a/server/site/style.css +++ b/server/site/style.css @@ -313,6 +313,32 @@ td.author { white-space: nowrap; } +/* Claude Code output tokens per commit (log and usage pages) */ +td.cost { + color: var(--site-muted); + white-space: nowrap; + text-align: right; +} + +td.cost .track { + display: inline-block; + width: 5rem; + height: 0.6rem; + margin-right: var(--spacing-sm); + vertical-align: baseline; +} + +td.cost .bar { + display: block; + height: 100%; + min-width: 2px; + background: var(--site-muted); +} + +td.cost .bar.hot { + background: var(--site-hunk); +} + /* File listing table */ .files td.dir a { font-weight: var(--font-weight-semibold); @@ -378,6 +404,7 @@ td.author { /* Markdown content */ .markdown img, +main p > img, img.blob { max-width: 100%; height: auto;