Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/good-job/ui.ts

Raw
import {
	DynamicBorder,
	type ExtensionContext,
	getMarkdownTheme,
	type Theme,
} from "@earendil-works/pi-coding-agent";
import {
	Container,
	type Focusable,
	Input,
	Markdown,
	matchesKey,
	Text,
	truncateToWidth,
} from "@earendil-works/pi-tui";
import type { GoodJobRecord } from "./core.js";
import type { GoodJobCounts } from "./storage.js";

function oneLine(text: string, maximum = 100): string {
	const compact = text.replace(/\s+/g, " ").trim();
	return Array.from(compact).length > maximum
		? `${Array.from(compact)
				.slice(0, maximum - 1)
				.join("")}…`
		: compact;
}

function dateLabel(timestamp: string): string {
	const date = new Date(timestamp);
	return Number.isNaN(date.valueOf())
		? "unknown date"
		: date.toISOString().slice(0, 10);
}

function projectLabel(record: GoodJobRecord): string {
	if (record.source.type === "import") return record.source.importer;
	const normalized = record.source.cwd.replaceAll("\\", "/").replace(/\/$/, "");
	return normalized.split("/").at(-1) || record.source.cwd;
}

function markdownText(text: string): string {
	return text.replace(/^([#>*+-])\s/gm, "\\$1 ");
}

export function formatLearningMarkdown(record: GoodJobRecord): string {
	const evidence = record.learning.evidence
		.map((item) => `- ${markdownText(item)}`)
		.join("\n");
	const behaviors = record.learning.behaviors
		.map((item) => `- ${markdownText(item)}`)
		.join("\n");
	const rule = record.learning.candidateRule
		? markdownText(record.learning.candidateRule)
		: "No durable rule proposed.";
	const sourceModel =
		record.source.type === "session" && record.source.agentModel
			? `${record.source.agentModel.provider}/${record.source.agentModel.id}`
			: record.source.type === "import"
				? record.source.importer
				: "unknown agent model";
	return [
		`## ${markdownText(record.learning.slug)}`,
		markdownText(record.learning.summary),
		"",
		"## Feedback",
		markdownText(record.feedback),
		"",
		"## Evidence",
		evidence,
		"",
		"## Behaviors",
		behaviors,
		"",
		"## Candidate rule",
		rule,
		"",
		`*${record.kind} · ${record.id} · ${dateLabel(record.completedAt)} · ${markdownText(projectLabel(record))} · ${markdownText(sourceModel)}*`,
	].join("\n");
}

function matchingRecords(
	records: readonly GoodJobRecord[],
	query: string,
): GoodJobRecord[] {
	const words = query.toLowerCase().trim().split(/\s+/).filter(Boolean);
	if (words.length === 0) return [...records];
	return records.filter((record) => {
		const haystack = [
			record.kind,
			record.learning.slug,
			record.feedback,
			record.learning.summary,
			record.learning.candidateRule ?? "",
			...record.learning.behaviors,
			projectLabel(record),
			record.id,
		]
			.join(" ")
			.toLowerCase();
		return words.every((word) => haystack.includes(word));
	});
}

export class LearningListComponent implements Focusable {
	private readonly search = new Input();
	private selected = 0;
	private _focused = false;

	constructor(
		private readonly theme: Theme,
		private readonly records: readonly GoodJobRecord[],
		private readonly counts: GoodJobCounts,
		private readonly requestRender: () => void,
		private readonly done: (id: string | null) => void,
	) {}

	get focused(): boolean {
		return this._focused;
	}

	set focused(value: boolean) {
		this._focused = value;
		this.search.focused = value;
	}

	handleInput(data: string): void {
		const matches = matchingRecords(this.records, this.search.getValue());
		if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
			this.done(null);
			return;
		}
		if (matchesKey(data, "up")) {
			this.selected =
				matches.length === 0
					? 0
					: (this.selected - 1 + matches.length) % matches.length;
			this.requestRender();
			return;
		}
		if (matchesKey(data, "down")) {
			this.selected =
				matches.length === 0 ? 0 : (this.selected + 1) % matches.length;
			this.requestRender();
			return;
		}
		if (matchesKey(data, "enter")) {
			this.done(matches[this.selected]?.id ?? null);
			return;
		}
		const before = this.search.getValue();
		this.search.handleInput(data);
		if (this.search.getValue() !== before) {
			this.selected = 0;
			this.requestRender();
		}
	}

	render(width: number): string[] {
		const safeWidth = Math.max(1, width);
		const matches = matchingRecords(this.records, this.search.getValue());
		this.selected = Math.min(this.selected, Math.max(0, matches.length - 1));
		const visible = windowed(matches, this.selected, 7);
		const first = matches.indexOf(visible[0] as GoodJobRecord);
		const queue = this.counts.pending + this.counts.processing;
		const status = [
			`${this.records.length} learnings`,
			queue ? `${queue} queued` : "",
			this.counts.failed ? `${this.counts.failed} failed` : "",
		]
			.filter(Boolean)
			.join(" · ");
		const lines = [
			this.theme.fg("border", "─".repeat(safeWidth)),
			`${this.theme.fg("accent", this.theme.bold("GJ / WTF"))}  ${this.theme.fg(this.counts.failed ? "warning" : "dim", status)}`,
			`search  ${this.search.render(Math.max(1, safeWidth - 8))[0] ?? ""}`,
			"",
		];
		if (visible.length === 0) {
			lines.push(this.theme.fg("warning", "No matching learnings."));
		} else {
			for (const [offset, record] of visible.entries()) {
				const selected = first + offset === this.selected;
				const marker = selected ? "▸" : " ";
				const summary = `${marker} ${record.learning.slug} · ${oneLine(record.learning.summary, 100)}`;
				lines.push(
					selected ? this.theme.fg("accent", summary) : summary,
					this.theme.fg(
						"dim",
						`  ${record.id} · ${dateLabel(record.completedAt)} · ${projectLabel(record)}`,
					),
				);
			}
		}
		lines.push(
			"",
			this.theme.fg(
				"dim",
				"type to filter · ↑↓ navigate · enter show · esc close",
			),
			this.theme.fg("border", "─".repeat(safeWidth)),
		);
		return lines.map((line) => truncateToWidth(line, safeWidth));
	}

	invalidate(): void {
		this.search.invalidate();
	}
}

function windowed<T>(
	items: readonly T[],
	selected: number,
	maximum: number,
): T[] {
	if (items.length <= maximum) return [...items];
	const half = Math.floor(maximum / 2);
	const start = Math.min(
		Math.max(0, selected - half),
		Math.max(0, items.length - maximum),
	);
	return items.slice(start, start + maximum);
}

export async function showLearningList(
	ctx: ExtensionContext,
	records: readonly GoodJobRecord[],
	counts: GoodJobCounts,
): Promise<string | null> {
	if (ctx.mode !== "tui") {
		ctx.ui.notify(
			records
				.slice(0, 10)
				.map(
					(record) =>
						`${record.id}  ${record.learning.slug}  ${dateLabel(record.completedAt)}  ${oneLine(record.learning.summary)}`,
				)
				.join("\n"),
			"info",
		);
		return null;
	}
	return ctx.ui.custom<string | null>(
		(tui, theme, _keybindings, done) =>
			new LearningListComponent(
				theme,
				records,
				counts,
				() => tui.requestRender(),
				done,
			),
	);
}

export async function showLearningDetail(
	ctx: ExtensionContext,
	record: GoodJobRecord,
): Promise<void> {
	const markdown = formatLearningMarkdown(record);
	if (ctx.mode !== "tui") {
		ctx.ui.notify(markdown, "info");
		return;
	}
	await ctx.ui.custom<void>((_tui, theme, _keybindings, done) => {
		const container = new Container();
		const border = () =>
			new DynamicBorder((text: string) => theme.fg("accent", text));
		container.addChild(border());
		container.addChild(
			new Text(theme.fg("accent", theme.bold("GJ / WTF")), 1, 0),
		);
		container.addChild(new Markdown(markdown, 1, 1, getMarkdownTheme()));
		container.addChild(new Text(theme.fg("dim", "enter/esc/q close"), 1, 0));
		container.addChild(border());
		return {
			render: (width: number) =>
				container.render(width).map((line) => truncateToWidth(line, width)),
			handleInput: (data: string) => {
				if (
					matchesKey(data, "enter") ||
					matchesKey(data, "escape") ||
					matchesKey(data, "q")
				)
					done();
			},
			invalidate: () => container.invalidate(),
		};
	});
}