// how lore shows itself in pi. one glyph per state, weight and brightness // before hue (the theme's roles carry the hue; nothing here depends on // telling two colors apart), a few lines at most, and a link out to the // space instead of a record explorer. import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import type { Theme } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; type LoreTheme = Pick; export const PDSLS = "https://pds.ls"; export const SITE = "https://lore.waow.tech"; export const GLYPH = { own: "●", shared: "◇", unbound: "○", ok: "✓", warn: "!", link: "↗" } as const; export type Line = { author: string; text: string }; export type Overview = { account: string; label: string; title: string; owner: string; mine: boolean; source: { key: string; kind: string; title: string }; space: string; members: number | null; lines: Line[]; total: number; all: boolean; }; export type Note = { tone: "ok" | "info" | "warn"; text: string; hint?: string }; export const PREVIEW = 5; const LINE_WIDTH = 96; export const spaceLink = (space: string): string => `${PDSLS}/${space}`; /** one line, ellipsized; entries are sentences, not documents */ export function clip(text: string, width = LINE_WIDTH): string { const flat = text.replace(/\s+/g, " ").trim(); return flat.length <= width ? flat : `${flat.slice(0, width - 1).trimEnd()}…`; } export type StatusView = Overview | { account: string; unbound: true } | { loggedOut: true } | { account: string; waiting: string }; /** how long a save dialog waits for an answer before the tool gives up */ export const CONFIRM_MS = 120_000; /** the three ways a save dialog ends. `unanswered` is a dialog nobody saw, which used to look * like an agent spinning forever: the tool was waiting, the status line said "working". */ export type Answer = "yes" | "no" | "unanswered"; export async function askToSave(confirm: (title: string, text: string, opts: { timeout: number }) => Promise, where: string, text: string, timeout = CONFIRM_MS, now = Date.now): Promise { const started = now(); const ok = await confirm(`save to ${where}?`, text, { timeout }); if (ok) return "yes"; return now() - started >= timeout ? "unanswered" : "no"; } /** what the footer shows all session: glyph, namespace, owner when shared, count, account */ export function statusLine(view: StatusView): string { if ("loggedOut" in view) return `${GLYPH.unbound} lore · not signed in`; if ("waiting" in view) return `${GLYPH.warn} lore · waiting for you: save to ${view.waiting}? answer the dialog · ${view.account}`; if ("unbound" in view) return `${GLYPH.unbound} lore · unbound · ${view.account}`; const owner = view.mine ? "" : ` ← ${view.owner}`; return `${view.mine ? GLYPH.own : GLYPH.shared} lore ${view.title}${owner} · ${view.total} · ${view.account}`; } /** the status as its own line below the editor, glyph in the accent role, the rest quiet */ export function renderStatus(view: StatusView, theme: LoreTheme): Text { const line = statusLine(view); const glyph = line.slice(0, 1); const rest = line.slice(1); if ("waiting" in view) return new Text(`${theme.fg("warning", glyph)}${theme.fg("text", rest)}`, 0, 0); const tone = "loggedOut" in view || "unbound" in view ? "dim" : "muted"; return new Text(`${theme.fg("accent", glyph)}${theme.fg(tone, rest)}`, 0, 0); } export function renderOverview(view: Overview, theme: LoreTheme): Text { const glyph = theme.fg("accent", view.mine ? GLYPH.own : GLYPH.shared); const name = theme.fg("accent", theme.bold(view.title)); const owner = view.mine ? theme.fg("dim", "yours") : theme.fg("muted", `← ${view.owner}`); const head = `${glyph} ${name} ${owner}`; const meta = [ `as ${view.account}`, view.source.kind === "name" ? undefined : `from ${view.source.title} (${view.source.kind})`, view.members === null ? undefined : view.members === 0 ? "no one else" : `${view.members} ${view.members === 1 ? "member" : "members"}`, ] .filter((m): m is string => m !== undefined) .join(" · "); const lines: string[] = [head, theme.fg("dim", ` ${meta}`)]; if (view.total === 0) { lines.push(theme.fg("muted", " nothing remembered here yet")); } else { const shown = view.all ? view.lines : view.lines.slice(0, PREVIEW); const width = Math.max(12, ...shown.map((l) => l.author.length)); for (const line of shown) { lines.push(` ${theme.fg("muted", line.author.padEnd(width))} ${theme.fg("text", clip(line.text))}`); } const rest = view.total - shown.length; if (rest > 0) lines.push(theme.fg("dim", ` +${rest} more · /lore all`)); } lines.push(` ${theme.fg("dim", GLYPH.link)} ${theme.fg("mdLinkUrl", spaceLink(view.space))}`); return new Text(lines.join("\n"), 0, 0); } /** the TUI face of a lore-update message: the same text, dimmed, under a glyph */ export function renderUpdateMessage(content: string | (TextContent | ImageContent)[], theme: LoreTheme): Text { const text = Array.isArray(content) ? content.flatMap((c) => (c.type === "text" ? [c.text] : [])).join("\n") : content; return new Text(`${theme.fg("accent", GLYPH.shared)} ${theme.fg("dim", text)}`, 0, 0); } export function renderNote(note: Note, theme: LoreTheme): Text { const glyph = note.tone === "ok" ? theme.fg("success", GLYPH.ok) : note.tone === "warn" ? theme.fg("warning", GLYPH.warn) : theme.fg("accent", "·"); const lines = [`${glyph} ${theme.fg("text", note.text)}`]; if (note.hint) lines.push(theme.fg("dim", ` ${note.hint}`)); return new Text(lines.join("\n"), 0, 0); }