pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
637 lines (636 loc) • 26.3 kB
JavaScript
import { stat } from "node:fs/promises";
import * as path from "node:path";
import { pathToFileURL } from "node:url";
import { visibleWidth } from "./deps/pi-tui.js";
import { fitLine } from "./tui-fit.js";
import { WriteOrderingGuard } from "./write-ordering-guard.js";
/**
* A diagnostic is "blocking" when pi-lens classifies it as a hard stop
* (`semantic === "blocking"`). Falls back to severity for sources that
* don't set `semantic` (raw tsc/eslint diagnostics) so the red dot still
* fires on traditional compile errors.
*/
function isBlocking(d) {
if (d.semantic === "blocking")
return true;
if (d.semantic == null && d.severity === "error")
return true;
return false;
}
// ── Module state ─────────────────────────────────────────────────────────────
const files = new Map();
const lspServers = new Map();
let sessionLanguages = [];
let requestRenderFn = null;
/**
* Guards `recordDiagnostics` writes against the same race class fixed for
* `clients/lsp/client.ts` in #555: pi-lens allows concurrent pipeline runs
* for the same file across different same-turn edits, so an older edit's
* (slower) pipeline can finish its `recordDiagnostics` call AFTER a newer
* edit's (faster) pipeline already recorded fresher diagnostics for that
* path. Keyed by `filePath`, tokened by `writeIndex` (see
* `clients/runtime-tool-result.ts:nextWriteIndex`).
*/
const diagnosticsWriteGuard = new WriteOrderingGuard();
const MAX_STORED_DIAGNOSTICS_PER_FILE = 12;
// ── Public API ────────────────────────────────────────────────────────────────
export function setRenderCallback(fn) {
requestRenderFn = fn;
}
export function clearWidgetState() {
files.clear();
lspServers.clear();
sessionLanguages = [];
requestRenderFn = null;
diagnosticsWriteGuard.clear();
}
const WIDGET_STATE_VERSION = 1;
/**
* Snapshot the per-file widget diagnostics for persistence (#190). Excludes
* `lspServers` — those are process-bound (servers re-spawn fresh on the next
* launch), so restoring their "ready" status would be misleading.
*/
export function exportWidgetState() {
return {
version: WIDGET_STATE_VERSION,
sessionLanguages: [...sessionLanguages],
files: [...files.values()].map((rec) => ({
filePath: rec.filePath,
runners: [...rec.runners.entries()],
formatters: [...rec.formatters.entries()],
diagnostics: rec.diagnostics,
allDiagnostics: rec.allDiagnostics,
diagnosticCounts: rec.diagnosticCounts,
hasFinalDiagnosticsSnapshot: rec.hasFinalDiagnosticsSnapshot,
touchedAt: rec.touchedAt,
})),
};
}
/**
* Restore a {@link PersistedWidgetState} snapshot (#190 resume rehydration).
* Replaces the in-memory `files` map; ignores snapshots from a different
* version. Triggers a re-render if a callback is registered.
*/
export function importWidgetState(state) {
if (!state || state.version !== WIDGET_STATE_VERSION)
return false;
files.clear();
// A resumed session's writeIndex counter starts fresh (#190 rehydration is
// process-bound like lspServers, see the export above) — any ordering
// tokens tracked before the restore no longer correspond to anything, so
// drop them rather than risk a legitimate post-resume write being read as
// "superseded" against a stale token.
diagnosticsWriteGuard.clear();
for (const f of state.files ?? []) {
files.set(f.filePath, {
filePath: f.filePath,
runners: new Map(f.runners ?? []),
formatters: new Map(f.formatters ?? []),
diagnostics: f.diagnostics ?? [],
allDiagnostics: f.allDiagnostics ?? [],
diagnosticCounts: f.diagnosticCounts ?? {
blocking: 0,
errors: 0,
warnings: 0,
},
hasFinalDiagnosticsSnapshot: f.hasFinalDiagnosticsSnapshot ?? false,
touchedAt: f.touchedAt ?? Date.now(),
});
}
sessionLanguages = state.sessionLanguages ?? [];
requestRenderFn?.();
return true;
}
export function setSessionLanguages(langs) {
sessionLanguages = langs;
requestRender();
}
/** File-kinds detected in use this session (#170 staleness scope). */
export function getSessionLanguages() {
return [...sessionLanguages];
}
/**
* Distinct serverIds with a failed spawn record (#170). Raw — the per-language
* coverage check (a live sibling) and the in-use staleness filter live in
* `selectLspStatus`, which joins this against the alive set and session kinds.
*/
export function getFailedLspServerIds() {
const ids = [];
const seen = new Set();
for (const rec of lspServers.values()) {
if (rec.status !== "failed" || seen.has(rec.serverId))
continue;
seen.add(rec.serverId);
ids.push(rec.serverId);
}
return ids;
}
export function recordFormatter(filePath, formatter, changed, success) {
const rec = getOrCreate(filePath);
rec.formatters.set(formatter, { changed, success });
rec.touchedAt = Date.now();
files.set(filePath, rec);
requestRender();
}
export function recordRunner(filePath, runnerId, status, diagnosticCount, durationMs) {
const rec = getOrCreate(filePath);
rec.runners.set(runnerId, { status, count: diagnosticCount, durationMs });
rec.hasFinalDiagnosticsSnapshot = false;
rec.touchedAt = Date.now();
files.set(filePath, rec);
requestRender();
}
/**
* Collapse a (possibly multi-line) diagnostic message to a single line.
* TS2769 / "no overload matches" and many compiler errors are multi-line;
* embedded newlines/tabs would otherwise render across several widget rows
* (and break the `L<line>: <message>` inline-blocker format), so flatten all
* whitespace runs to a single space before storing.
*/
function toSingleLineMessage(message) {
return (message ?? "").replace(/\s+/g, " ").trim();
}
export function recordDiagnostics(filePath, diagnostics, writeIndex) {
// Drop a write that's superseded by a later same-turn edit to this file
// whose pipeline finished first (same race class as #555). No cache write,
// no count/timestamp update, no render trigger — the recorded state must
// stay exactly as the fresher write left it. `writeIndex` omitted (e.g.
// the `clients/mcp/analyze.ts` on-demand call site, which has no per-edit
// ordering token) always proceeds, same as version-less LSP servers in the
// #555 guard.
if (!diagnosticsWriteGuard.shouldWrite(filePath, writeIndex))
return;
const rec = getOrCreate(filePath);
const base = pathToFileURL(filePath).href;
const normalized = diagnostics.map((d) => {
const rule = d.rule ?? d.id;
const uri = d.line != null
? `${base}#L${d.line}${d.column != null ? `:${d.column}` : ""}`
: base;
return {
severity: d.severity ?? "info",
semantic: d.semantic,
message: toSingleLineMessage(d.message),
line: d.line,
col: d.column,
rule,
tool: d.tool,
uri,
};
});
let blocking = 0;
let errors = 0;
let warnings = 0;
for (const diagnostic of normalized) {
if (isBlocking(diagnostic))
blocking++;
if (diagnostic.severity === "error")
errors++;
else if (diagnostic.severity === "warning")
warnings++;
}
rec.diagnosticCounts = { blocking, errors, warnings };
rec.diagnostics = capStoredDiagnostics(normalized);
rec.allDiagnostics = normalized;
rec.hasFinalDiagnosticsSnapshot = true;
rec.touchedAt = Date.now();
files.set(filePath, rec);
requestRender();
}
/**
* Reconcile a diagnostics result obtained OUTSIDE the per-edit dispatch
* pipeline — a `lens_diagnostics` mode=full workspace scan, or a standalone
* `lsp_diagnostics` on-demand check — into the footer cache (#571).
*
* `recordDiagnostics` is otherwise only reachable from `pipeline.ts`'s
* per-edit dispatch, so a file that becomes stale/fresh purely because of a
* change to some OTHER file it depends on (and is never itself re-edited
* through pi-lens) has no path to correct the footer — a full scan proves
* the fresher truth but had nowhere to put it. This is that path, shared by
* both call sites so there's exactly one place that decides whether a scan
* result is trustworthy enough to write.
*
* `confirmed` MUST be false for any result the caller can't vouch for — a
* timed-out/inconclusive LSP check (see #570) must never present as
* "confirmed clean" in the footer, and must not clobber a real prior
* confirmed-dirty entry either. Non-confirmed results are silently skipped,
* leaving whatever the footer already had (stale-but-real beats
* fresh-but-fabricated).
*
* `writeIndex` should be a freshly-drawn token from the same monotonic
* source the per-edit pipeline uses (`RuntimeCoordinator.nextWriteIndex()`)
* so `recordDiagnostics`'s existing `WriteOrderingGuard` (#555) can tell a
* scan-originated write apart from a concurrent, genuinely newer per-edit
* write for the same file — an omitted `writeIndex` always proceeds (same
* version-less fallback `recordDiagnostics` already documents), which is
* only safe for callers with no ordering token to give (e.g. tests).
*/
export function reconcileScanDiagnostics(filePath, diagnostics, confirmed, writeIndex) {
if (!confirmed)
return;
recordDiagnostics(filePath, diagnostics, writeIndex);
}
/**
* Drop widget entries whose file changed on disk after pi-lens last recorded
* them (`mtimeMs > touchedAt` → the recorded diagnostics predate the current
* content → stale) or that no longer exist. Keeps `lens_diagnostics` from
* surfacing findings the agent already fixed (or that an external edit
* invalidated). Async with concurrent stats — call on read, never on the typing
* path. Returns how many entries were dropped (so callers can tell the agent
* those files changed and need a `mode=full` rescan rather than reading as
* clean).
*/
export async function reconcileStaleWidgetFiles() {
const entries = [...files.entries()];
const staleKeys = await Promise.all(entries.map(async ([filePath, rec]) => {
try {
const st = await stat(filePath);
// +1ms tolerance: a freshly-recorded file has touchedAt >= mtime.
return st.mtimeMs > rec.touchedAt + 1 ? filePath : undefined;
}
catch {
return filePath; // deleted / unreadable → drop
}
}));
let dropped = 0;
for (const key of staleKeys) {
if (key !== undefined) {
files.delete(key);
dropped += 1;
}
}
if (dropped > 0)
requestRenderFn?.();
return dropped;
}
/**
* Return current diagnostics for every file pi-lens has seen this session.
* Used by lens_diagnostics tool (mode: "all"). Exposes the FULL per-file
* diagnostic set — decoupled from the widget's display cap — so the agent sees
* everything, not just the 12 the TUI keeps for rendering.
*/
export function getFileDiagnosticSummaries() {
return [...files.values()].map((rec) => ({
filePath: rec.filePath,
blocking: rec.diagnosticCounts.blocking,
errors: rec.diagnosticCounts.errors,
warnings: rec.diagnosticCounts.warnings,
hasFinalSnapshot: rec.hasFinalDiagnosticsSnapshot,
diagnostics: rec.allDiagnostics.map((d) => ({ ...d })),
}));
}
/**
* Return the current FULL (uncapped) diagnostic set for a single file, as
* last recorded by {@link recordDiagnostics} — the same `allDiagnostics`
* store `getFileDiagnosticSummaries` exposes per-file, without paying for a
* whole-session snapshot. Used by the #502 `pilens:diagnostics` bus producer
* (`clients/bus-publish.ts`), which reads this immediately after
* `recordDiagnostics` writes it so the emitted event reflects the write
* batch's FINAL diagnostic state (post-format, post-autofix, post-dispatch —
* see pipeline.ts call order). Returns `undefined` when the file has never
* been recorded (caller must not confuse "never seen" with "seen and clean";
* an explicit `[]` from `recordDiagnostics` is a real empty array here).
*
* NOTE: `filePath` must be the exact string used to record the file — the
* `files` map key is NOT normalized (pre-existing; see `getOrCreate`), so
* callers should pass through the same value they gave `recordDiagnostics`.
*/
export function getFileDiagnostics(filePath) {
const rec = files.get(filePath);
if (!rec)
return undefined;
return rec.allDiagnostics.map((d) => ({ ...d }));
}
/** @internal Test-only helpers. Do not use in production code. */
export const __testing = {
getWidgetStateSnapshot() {
return {
files: [...files.values()].map((rec) => ({
filePath: rec.filePath,
storedDiagnostics: rec.diagnostics.length,
blocking: rec.diagnosticCounts.blocking,
errors: rec.diagnosticCounts.errors,
warnings: rec.diagnosticCounts.warnings,
})),
};
},
};
export function recordLsp(serverId, root, status, durationMs) {
const key = `${serverId}@${root}`;
const mapped = status === "spawn_start"
? "spawning"
: status === "spawn_success"
? "ready"
: "failed";
lspServers.set(key, { serverId, root, status: mapped, durationMs });
requestRender();
}
// ── Render ────────────────────────────────────────────────────────────────────
const HORIZONTAL_MIN_WIDTH = 70;
export function renderWidget(width, theme) {
const dim = (s) => theme.fg("dim", s);
const red = (s) => theme.fg("error", s);
const yellow = (s) => theme.fg("warning", s);
const green = (s) => theme.fg("success", s);
const cyan = (s) => theme.fg("accent", s);
const w = Math.max(1, width || 80);
const useHorizontal = w >= HORIZONTAL_MIN_WIDTH;
if (files.size === 0 && lspServers.size === 0)
return [];
const lines = [];
// Header — counts from deduplicated files only
const deduped = dedupeByBasename([...files.values()]);
const recencySorted = deduped.filter(shouldRenderFile).slice(0, 5);
const langStr = sessionLanguages.slice(0, 6).join(" ");
const totalBlocking = countBlockingIn(deduped);
const totalErrors = countTotalIn("error", deduped);
const totalWarnings = countTotalIn("warning", deduped);
const hasPendingAnalysis = deduped.some(isPendingAnalysis);
const errorChunk = totalErrors > 0
? (totalBlocking > 0 ? red : yellow)(`●${totalErrors}E`)
: "";
const warningChunk = totalWarnings > 0 ? yellow(`!${totalWarnings}W`) : "";
const summary = errorChunk
? errorChunk + (warningChunk ? " " + warningChunk : "")
: warningChunk
? warningChunk
: files.size > 0 && !hasPendingAnalysis
? green("✓ clean")
: "";
// LSP spawning — folded into the header in horizontal mode, tail line otherwise
const spawning = [...lspServers.values()].filter((s) => s.status === "spawning");
const lspChip = useHorizontal && spawning.length > 0 ? " " + dim("LSP↑") : "";
const header = ` ${cyan("pi-lens")}${langStr ? " " + dim(langStr) : ""}${lspChip}${summary ? " " + summary : ""}`;
lines.push(fitLine(header, w));
// File list — display order varies by mode
if (useHorizontal) {
const displayOrder = sortByTierThenRecency(recencySorted);
const rowLine = packHorizontalRow(displayOrder, w, theme);
if (rowLine.length > 0)
lines.push(rowLine);
}
else {
for (const rec of recencySorted) {
lines.push(fitLine(formatFileRowVertical(rec, theme), w));
}
}
// Diagnostics — blocking only, from the most recently touched file that has them.
// Vertical mode keeps the divider/filename context; horizontal already shows the
// filename on the packed row above, so we drop the extra header noise there.
const withBlocking = recencySorted.filter((r) => r.diagnostics.some(isBlocking));
if (withBlocking.length > 0) {
const rec = withBlocking[0];
if (!useHorizontal) {
lines.push(fitLine(dim("─".repeat(Math.min(w, 60))), w));
lines.push(fitLine(` ${dim(path.basename(rec.filePath))}`, w));
}
const blockers = rec.diagnostics.filter(isBlocking).slice(0, 5);
for (const d of blockers) {
const loc = d.line != null ? osc8(d.uri ?? "", `L${d.line}`) : "";
const rule = d.rule ? dim(` ${d.rule}`) : "";
const prefix = ` ${red("●")} ${loc}${rule} `;
const msgWidth = Math.max(1, w - visibleWidth(prefix));
const msg = fitLine(d.message, msgWidth, "…");
lines.push(fitLine(`${prefix}${msg}`, w));
}
}
// LSP status tail — only in vertical mode; horizontal folds into header
if (!useHorizontal && spawning.length > 0) {
const ids = spawning.map((s) => s.serverId).join(" ");
lines.push(fitLine(` ${dim(`LSP spawning: ${ids}`)}`, w));
}
return lines;
}
function classifyFileTier(rec) {
if (rec.diagnosticCounts.blocking > 0)
return "blocking";
if (rec.diagnosticCounts.errors > 0 || rec.diagnosticCounts.warnings > 0) {
return "warning";
}
return "clean";
}
function sortByTierThenRecency(recs) {
const order = {
blocking: 0,
warning: 1,
clean: 2,
};
return [...recs].sort((a, b) => {
const ta = order[classifyFileTier(a)];
const tb = order[classifyFileTier(b)];
if (ta !== tb)
return ta - tb;
return b.touchedAt - a.touchedAt;
});
}
function formatFileRowVertical(rec, theme) {
const dim = (s) => theme.fg("dim", s);
const red = (s) => theme.fg("error", s);
const yellow = (s) => theme.fg("warning", s);
const green = (s) => theme.fg("success", s);
const base = path.basename(rec.filePath);
const blocking = rec.diagnosticCounts.blocking;
const errors = rec.diagnosticCounts.errors;
const warnings = rec.diagnosticCounts.warnings;
const dot = blocking > 0
? red("●")
: warnings > 0 || errors > 0
? yellow("!")
: green("✓");
const runnerNames = [...rec.runners.entries()]
.filter(([, r]) => r.status !== "skipped")
.map(([id]) => id)
.join(" ");
const counts = errors > 0
? " " +
(blocking > 0 ? red : yellow)(`${errors}E`) +
(warnings > 0 ? " " + yellow(`${warnings}W`) : "")
: warnings > 0
? " " + yellow(`${warnings}W`)
: " " + dim("clean");
const changedFormatters = [...rec.formatters.entries()]
.filter(([, f]) => f.changed)
.map(([name]) => name);
const formatMark = changedFormatters.length > 0
? dim(` fmt:${changedFormatters.join(",")}`)
: "";
return ` ${dot} ${base} ${dim(runnerNames)}${formatMark}${counts}`;
}
function packHorizontalRow(recs, totalWidth, theme) {
if (recs.length === 0)
return "";
const dim = (s) => theme.fg("dim", s);
const indent = " ";
const sep = " ";
// Reserve worst-case overflow space upfront so the marker always fits.
// " +NN" — 4 visible chars covers up to two-digit overflow.
const overflowReserve = 4;
let used = visibleWidth(indent);
const parts = [indent];
const addedTokenWidths = [];
let droppedAt = -1;
for (let i = 0; i < recs.length; i++) {
const sepWidth = parts.length > 1 ? visibleWidth(sep) : 0;
const willOverflow = i < recs.length - 1;
const reserve = willOverflow ? overflowReserve : 0;
const remaining = totalWidth - used - sepWidth - reserve;
if (remaining < 4) {
droppedAt = i;
break;
}
const token = formatFileTokenHorizontal(recs[i], remaining, theme);
const tokenWidth = visibleWidth(token);
if (token.length === 0 || used + sepWidth + tokenWidth > totalWidth) {
droppedAt = i;
break;
}
if (sepWidth > 0) {
parts.push(sep);
used += sepWidth;
}
parts.push(token);
used += tokenWidth;
addedTokenWidths.push(tokenWidth + sepWidth);
}
if (droppedAt >= 0) {
let dropped = recs.length - droppedAt;
let overflow = " " + dim(`+${dropped}`);
// If reservation was insufficient (e.g. last token grew because no
// reserve was applied), shed accepted tokens until overflow fits.
while (used + visibleWidth(overflow) > totalWidth &&
addedTokenWidths.length > 0) {
const lastWidth = addedTokenWidths.pop();
used -= lastWidth;
parts.pop(); // token
if (parts.length > 1)
parts.pop(); // preceding separator
dropped++;
overflow = " " + dim(`+${dropped}`);
}
if (used + visibleWidth(overflow) <= totalWidth) {
parts.push(overflow);
}
}
return fitLine(parts.join(""), totalWidth);
}
function formatFileTokenHorizontal(rec, remainingWidth, theme) {
const dim = (s) => theme.fg("dim", s);
const red = (s) => theme.fg("error", s);
const yellow = (s) => theme.fg("warning", s);
const blocking = rec.diagnosticCounts.blocking;
const errors = rec.diagnosticCounts.errors;
const warnings = rec.diagnosticCounts.warnings;
const formatterChanged = hasChangedFormatter(rec);
let dotChar;
if (blocking > 0)
dotChar = red("●");
else if (errors > 0 || warnings > 0)
dotChar = yellow("!");
else if (formatterChanged)
dotChar = dim("✎");
else
dotChar = dim("·");
let countsStyled = "";
if (errors > 0 && warnings > 0) {
const eColor = blocking > 0 ? red : yellow;
countsStyled = " " + eColor(`${errors}E`) + yellow(`${warnings}W`);
}
else if (errors > 0) {
const eColor = blocking > 0 ? red : yellow;
countsStyled = " " + eColor(`${errors}E`);
}
else if (warnings > 0) {
countsStyled = " " + yellow(`${warnings}W`);
}
const fullBasename = path.basename(rec.filePath);
const fixedWidth = visibleWidth(dotChar) + 1 + visibleWidth(countsStyled);
const basenameBudget = remainingWidth - fixedWidth;
if (basenameBudget < 3)
return "";
const truncated = truncateBasename(fullBasename, basenameBudget);
const linked = osc8(pathToFileURL(rec.filePath).href, truncated);
return `${dotChar} ${linked}${countsStyled}`;
}
function truncateBasename(name, maxWidth) {
if (visibleWidth(name) <= maxWidth)
return name;
if (maxWidth < 2)
return "…";
const ext = path.extname(name);
const stem = name.slice(0, name.length - ext.length);
const keep = maxWidth - ext.length - 1;
if (keep < 1) {
// Extension alone wouldn't fit; truncate the whole name.
return name.slice(0, maxWidth - 1) + "…";
}
return stem.slice(0, keep) + "…" + ext;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function getOrCreate(filePath) {
return (files.get(filePath) ?? {
filePath,
runners: new Map(),
formatters: new Map(),
diagnostics: [],
allDiagnostics: [],
diagnosticCounts: { blocking: 0, errors: 0, warnings: 0 },
hasFinalDiagnosticsSnapshot: false,
touchedAt: Date.now(),
});
}
function hasChangedFormatter(rec) {
return [...rec.formatters.values()].some((f) => f.changed);
}
function shouldRenderFile(rec) {
return rec.hasFinalDiagnosticsSnapshot || hasChangedFormatter(rec);
}
function isPendingAnalysis(rec) {
return rec.runners.size > 0 && !rec.hasFinalDiagnosticsSnapshot;
}
function capStoredDiagnostics(diagnostics) {
if (diagnostics.length <= MAX_STORED_DIAGNOSTICS_PER_FILE)
return diagnostics;
const blockers = diagnostics.filter(isBlocking);
if (blockers.length >= MAX_STORED_DIAGNOSTICS_PER_FILE) {
return blockers.slice(0, MAX_STORED_DIAGNOSTICS_PER_FILE);
}
const rest = diagnostics.filter((d) => !isBlocking(d));
return [
...blockers,
...rest.slice(0, MAX_STORED_DIAGNOSTICS_PER_FILE - blockers.length),
];
}
function countTotalIn(severity, recs) {
let n = 0;
for (const rec of recs) {
if (severity === "error")
n += rec.diagnosticCounts.errors;
else
n += rec.diagnosticCounts.warnings;
}
return n;
}
function countBlockingIn(recs) {
let n = 0;
for (const rec of recs)
n += rec.diagnosticCounts.blocking;
return n;
}
function requestRender() {
requestRenderFn?.();
}
function osc8(uri, label) {
if (!uri)
return label;
return `\x1b]8;;${uri}\x1b\\${label}\x1b]8;;\x1b\\`;
}
// Dual-signature truncateToWidth handling lives in tui-fit.ts (shared with the
// turn-summary message renderer, which learned the hard way that pi-tui crashes
// the host on over-width lines — #513).
function dedupeByBasename(recs) {
const seen = new Map();
for (const r of [...recs].sort((a, b) => a.touchedAt - b.touchedAt)) {
seen.set(path.basename(r.filePath), r);
}
return [...seen.values()].sort((a, b) => b.touchedAt - a.touchedAt);
}