repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/good-job/ui.ts
Rawimport {
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(),
};
});
}