diff --git a/README.md b/README.md index 5fc1cfc..a128e55 100644 --- a/README.md +++ b/README.md @@ -382,15 +382,29 @@ moshcode cost --json # for a script ``` ``` - session engine model in out cache cost age - api claude claude-opus-5 1.2k 27k 10.5M $9.91~ 42m - audit codex gpt-5.6-sol 400 200 600 — 12m + session engine model in out cache cost age pr + api claude claude-opus-5 1.2k 27k 10.5M $9.91~ 42m view #128 + audit codex gpt-5.6-sol 400 200 600 — 12m — total $9.91~ 1.6k in · 27k out · 10.5M cached ~ estimated from published rates; unmarked figures are the engine's own. ⚠ no rate for gpt-5.6-sol — tokens counted, cost omitted. ``` +**`view` is a link — click it and the PR opens in your browser.** The cost table +is where you notice a session that cost $300, and the next thing you want is +the thing it produced, which lives on GitHub rather than on this machine. The +cell is an [OSC 8](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) +hyperlink: the label stays four characters wide while the click target is the +full URL, so the column costs nothing to carry. Claude Code writes a `pr-link` +record when a session opens a pull request, and that is where this comes from — +other engines leave the column blank because they record nothing like it. + +Not every terminal speaks OSC 8, and there is no way to ask one whether it does. +Piped output prints the raw URL instead, and `MOSHCODE_HYPERLINKS=0` forces that +same plain form in a terminal that would otherwise paint a "view" nobody can +click. `moshcode cost --json` always carries `pr` and `prs` in full. + | engine | where the number comes from | |---|---| | claude | per-message `usage` in `~/.claude/projects/**/*.jsonl` | diff --git a/src/cost-cli.mjs b/src/cost-cli.mjs index f4ad8a7..aae76ce 100644 --- a/src/cost-cli.mjs +++ b/src/cost-cli.mjs @@ -14,7 +14,7 @@ import { } from "./cost.mjs"; import { pricingFile } from "./cost-pricing.mjs"; import { EXIT, humanAge, roster } from "./herd-cli.mjs"; -import { acid, ash, bone, dim, err, info, table, warn } from "./ui.mjs"; +import { acid, ash, bone, dim, err, info, link, table, warn } from "./ui.mjs"; const tilde = (p) => { const home = process.env.HOME || ""; @@ -60,6 +60,22 @@ function costCell(cost, source) { */ const cacheTokens = (u) => u.cacheRead + u.cacheWrite5m + u.cacheWrite1h; +/** + * The PR this session opened, as one clickable word. + * + * A cost table is where you notice a session that cost $300, and the next thing + * you want is the thing it produced — which lives on github.com, not on this + * machine. OSC 8 lets the cell read "view #123" while the click target is the + * full URL, so the column stays four characters wide instead of sixty. The URL + * itself is the fallback when hyperlinks are off (piped output, or a terminal + * without OSC 8), because an unreachable "view" would be worse than a long cell. + */ +function prCell(pr) { + if (!pr?.url) return ash("—"); + const label = pr.number == null ? "view" : `view #${pr.number}`; + return link(acid(label), pr.url, { fallback: pr.url }); +} + /** The per-session table, shared by the one-shot report and `--watch`. */ export function renderCost(rows, { indent = " " } = {}) { if (!rows.length) return ""; @@ -73,8 +89,9 @@ export function renderCost(rows, { indent = " " } = {}) { dim(formatTokens(cacheTokens(r.usage))), costCell(r.cost, r.costSource), dim(humanAge(r.age)), + prCell(r.pr), ]), - { columns: ["session", "engine", "model", "in", "out", "cache", "cost", "age"], header: true, indent: indent.length }, + { columns: ["session", "engine", "model", "in", "out", "cache", "cost", "age", "pr"], header: true, indent: indent.length }, ); } @@ -91,8 +108,9 @@ function renderRuns(runs, { indent = " " } = {}) { dim(formatTokens(r.usage.output)), dim(formatTokens(cacheTokens(r.usage))), costCell(r.cost, r.costSource), + prCell(r.pr), ]), - { columns: ["run", "engine", "model", "cwd", "in", "out", "cache", "cost"], header: true, indent: indent.length }, + { columns: ["run", "engine", "model", "cwd", "in", "out", "cache", "cost", "pr"], header: true, indent: indent.length }, ); } @@ -164,12 +182,12 @@ export async function costCommand(argv = [], { write = console.log } = {}) { if (asJson) { write(JSON.stringify({ since, - sessions: rows.map(({ name: n, engine, cwd, state, models, usage, cost, costSource, unpriced, runs }) => ({ - name: n, engine, cwd, state, models, usage, cost, costSource, unpriced, - runs: runs.map((r) => ({ id: r.id, model: r.model, usage: r.usage, cost: r.cost, costSource: r.costSource, start: r.start, end: r.end })), + sessions: rows.map(({ name: n, engine, cwd, state, models, usage, cost, costSource, unpriced, pr, prs, runs }) => ({ + name: n, engine, cwd, state, models, usage, cost, costSource, unpriced, pr, prs, + runs: runs.map((r) => ({ id: r.id, model: r.model, usage: r.usage, cost: r.cost, costSource: r.costSource, start: r.start, end: r.end, pr: r.pr ?? null })), })), unattributed: report.unattributed.map((r) => ({ - id: r.id, engine: r.engine, cwd: r.cwd, model: r.model, usage: r.usage, cost: r.cost, costSource: r.costSource, start: r.start, end: r.end, + id: r.id, engine: r.engine, cwd: r.cwd, model: r.model, usage: r.usage, cost: r.cost, costSource: r.costSource, start: r.start, end: r.end, pr: r.pr ?? null, })), totals: totals([...rows, ...report.unattributed]), }, null, 2)); diff --git a/src/cost.mjs b/src/cost.mjs index 10dc88d..46d7b37 100644 --- a/src/cost.mjs +++ b/src/cost.mjs @@ -151,6 +151,28 @@ function claudeUsageOf(message) { }; } +/** + * A `pr-link` record → the shape the table renders, or null. + * + * The URL is the only required field, because it is the only one the terminal + * can act on; a record without one is a row we cannot make clickable and is + * better dropped than shown as a dead link. Only github.com and GitHub + * Enterprise-shaped https URLs are accepted — this string ends up in an OSC 8 + * escape handed to the user's browser, and a transcript is a file on disk that + * other tools write to. + */ +function claudePrOf(entry) { + const url = String(entry?.prUrl || "").trim(); + if (!/^https:\/\/[A-Za-z0-9.-]+\/[^\s]*$/.test(url)) return null; + const number = Number(entry?.prNumber); + return { + number: Number.isFinite(number) ? number : null, + url, + repository: String(entry?.prRepository || ""), + at: stamp(entry?.timestamp), + }; +} + /** One Claude Code transcript → one run, or null when it holds no usage. */ function readClaudeTranscript(file, { since }) { let text; @@ -165,11 +187,23 @@ function readClaudeTranscript(file, { since }) { let end = null; let cwd = ""; let id = path.basename(file, ".jsonl"); + let pr = null; for (const line of text.split("\n")) { if (!line || line.charCodeAt(0) !== 123) continue; // fast reject: not "{" const entry = parseJson(line); - if (!entry || entry.type !== "assistant") continue; + if (!entry) continue; + // Claude Code writes a `pr-link` record when a session opens a pull + // request. It is the only place the transcript says what the work turned + // into, and it is deliberately read outside the `since` window: a PR opened + // yesterday still belongs to the session that shows up in today's table. + // Latest wins — a session that opened two PRs points at the newer one. + if (entry.type === "pr-link") { + const found = claudePrOf(entry); + if (found && (!pr || (found.at ?? 0) >= (pr.at ?? 0))) pr = found; + continue; + } + if (entry.type !== "assistant") continue; const at = stamp(entry.timestamp); if (at != null && since != null && at < since) continue; @@ -199,7 +233,7 @@ function readClaudeTranscript(file, { since }) { } if (!seen.size) return null; - return { engine: "claude", id, cwd, usage, byModel, start, end, engineCost: hasEngineCost ? engineCost : null }; + return { engine: "claude", id, cwd, usage, byModel, start, end, pr, engineCost: hasEngineCost ? engineCost : null }; } function claudeRuns({ since, cwd } = {}) { @@ -678,7 +712,7 @@ export async function engineRuns({ * line. */ export function attributeRuns(sessions = [], runs = []) { - const rows = sessions.map((s) => ({ ...s, runs: [], usage: { ...EMPTY_USAGE }, cost: null, costSource: null, unpriced: [] })); + const rows = sessions.map((s) => ({ ...s, runs: [], usage: { ...EMPTY_USAGE }, cost: null, costSource: null, unpriced: [], pr: null, prs: [] })); const unattributed = []; for (const run of runs) { @@ -707,6 +741,12 @@ export function attributeRuns(sessions = [], runs = []) { } row.unpriced = [...new Set(row.unpriced)]; row.models = [...new Set(row.runs.flatMap((r) => r.models))]; + // A session can resume across several transcripts and open a PR from any of + // them. All of them are kept for `--json`; the table has one cell, so it + // gets the newest — the PR this session is working on now. + row.prs = [...new Map(row.runs.map((r) => r.pr).filter(Boolean).map((p) => [p.url, p])).values()] + .sort((a, b) => (a.at ?? 0) - (b.at ?? 0)); + row.pr = row.prs.length ? row.prs[row.prs.length - 1] : null; } return { rows, unattributed }; diff --git a/src/ui.mjs b/src/ui.mjs index 0b06a68..7d846fd 100644 --- a/src/ui.mjs +++ b/src/ui.mjs @@ -59,9 +59,14 @@ export function hr() { // it wrong is how a table's right edge goes ragged the moment one cell is // coloured, and every caller here paints cells. const ANSI = /\x1b\[[0-9;]*m/g; +// OSC 8 hyperlinks wrap a label in two escape sequences whose payload is a URL +// — tens of characters that print nothing. They are not SGR, so ANSI above does +// not match them, and a table measured without this would size its columns to +// the length of a GitHub URL and blow the layout apart. +const OSC8 = /\x1b\]8;.*?(?:\x07|\x1b\\)/g; -/** `text` with every SGR sequence removed. */ -export const strip = (s) => String(s ?? "").replace(ANSI, ""); +/** `text` with every SGR sequence and OSC 8 hyperlink wrapper removed. */ +export const strip = (s) => String(s ?? "").replace(ANSI, "").replace(OSC8, ""); /** Printable width of `text` in terminal columns, colour codes not counted. */ export const visible = (s) => strip(s).length; @@ -109,13 +114,25 @@ export function clip(text, width, { collapse = true } = {}) { let out = ""; let printed = 0; let painted = false; + let linked = false; for (let i = 0; i < s.length && printed < room; i++) { if (s[i] === "\x1b") { - const match = /^\x1b\[[0-9;]*m/.exec(s.slice(i)); - if (match) { - out += match[0]; + const colour = /^\x1b\[[0-9;]*m/.exec(s.slice(i)); + if (colour) { + out += colour[0]; painted = true; - i += match[0].length - 1; + i += colour[0].length - 1; + continue; + } + // An OSC 8 wrapper is copied whole for the same reason a colour code is: + // half of one is garbage on screen. `linked` tracks whether an opener has + // gone past without its closer, because a cut landing inside a hyperlink + // would otherwise leave the terminal linking everything printed after it. + const href = /^\x1b\]8;.*?(?:\x07|\x1b\\)/.exec(s.slice(i)); + if (href) { + out += href[0]; + linked = !LINK_CLOSE_RE.test(href[0]); + i += href[0].length - 1; continue; } } @@ -125,7 +142,50 @@ export function clip(text, width, { collapse = true } = {}) { // A full reset rather than ui.mjs's narrower `\x1b[39m`: the cut may have // landed inside dim, or inside a colour some caller opened around us, and // leaking either is the bug this function exists to avoid. - return out + (painted ? "\x1b[0m" : "") + "…"; + return out + (linked ? LINK_CLOSE : "") + (painted ? "\x1b[0m" : "") + "…"; +} + +/* ------------------------------------------------------------ hyperlinks */ + +// OSC 8: ESC ]8;; ST