/** * Terminal UI for the memory extension: the `/memory` picker. * * `MemoryPickerComponent` is a derived dock screen. It reads the live notes on * every render (`entries()` refreshes from disk first) and asks for a repaint * after an action settles, so an external edit or a mutation is reflected * without the component owning any authoritative state. Navigation and scroll * math come from `lib/tui/cursor.ts` and the screen chrome from `lib/tui/chrome.ts`; * the inline note editor is the shared `Editor` from `@earendil-works/pi-tui`. */ import type { Theme } from "@earendil-works/pi-coding-agent"; import { Editor, Key, matchesKey, type Component, type TUI, type TuiMouseEvent, type TuiMouseEventResult, } from "@earendil-works/pi-tui"; import { sanitizeLine } from "../../lib/format.ts"; import { fitRows, formatRange, isCloseKey, ListCursor, navigateList, scrollList } from "../../lib/tui/cursor.ts"; import { dockListFrame, editorTheme, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts"; import { clip } from "../../lib/tui/columns.ts"; import { emptyState, listRow } from "../../lib/tui/rows.ts"; import { titleRule } from "../../lib/tui/rules.ts"; import { paint } from "../../lib/tui/tokens.ts"; import { messageOf } from "./errors.ts"; import { scopeLabel, type MemoryEntries } from "./format.ts"; import type { MemoryScope } from "./types.ts"; /** Note limit enforced by the `memory` tool; the picker mirrors it so a save is never rejected late. */ const MAX_NOTE_CHARS = 500; /** Rows the screen shows when the terminal height is unknown. */ const SCREEN_DEFAULT_ROWS = 14; /** Fixed rows around the list: header, summary, blank, blank, hint, blank. */ const CHROME = 6; /** Which scopes the list shows. */ export type MemoryFilter = "all" | MemoryScope; /** The live store and the mutations the picker drives. */ export interface MemoryPickerCallbacks { /** Current notes, refreshed from disk first. */ entries(): MemoryEntries; /** Whether the project store may be read and written. */ trusted(): boolean; /** Re-read any changed store; returns whether either changed. */ refresh(): boolean; add(scope: MemoryScope, text: string): Promise<{ changed: boolean; existing?: string }>; update(scope: MemoryScope, from: string, to: string): Promise; forget(scope: MemoryScope, entry: string): Promise; } /** One note in the flat, filtered list. */ interface Note { scope: MemoryScope; text: string; } /** Identity of a note: its scope and exact text (unique within a scope). */ function noteKey(note: Note): string { return `${note.scope}\u0000${note.text}`; } /** Dismissible `/memory` screen: a scope-filtered note list with inline actions. */ export class MemoryPickerComponent implements Component { private readonly cursor: ListCursor; private readonly editor: Editor; private filter: MemoryFilter = "all"; private mode: "list" | "edit" | "add" = "list"; /** Scope the inline editor writes to. */ private editorScope: MemoryScope = "project"; /** Note being edited, or undefined while adding. */ private editing: Note | undefined; /** Note awaiting a y/N forget confirmation. */ private confirm: Note | undefined; /** Transient feedback shown in place of the hint. */ private notice: string | undefined; private componentFocused = false; /** Stable selection identity, re-derived when the filtered list changes. */ private selectedKey: string | undefined; /** Serialises mutations so a rapid double press cannot interleave writes. */ private chain: Promise = Promise.resolve(); constructor( tui: TUI, private readonly theme: Theme, private readonly callbacks: MemoryPickerCallbacks, private readonly onClose: () => void, private readonly requestRender: () => void, private readonly viewportRowsSource?: ViewportRowsSource, ) { this.cursor = new ListCursor(() => this.requestRender()); this.editor = new Editor(tui, editorTheme(theme)); this.editor.onSubmit = (value) => this.submitEditor(value); } /** The filter, downgraded to `global` when the project is untrusted. */ private effectiveFilter(): MemoryFilter { return this.filter === "project" && !this.callbacks.trusted() ? "global" : this.filter; } /** The flat, filtered note list, in project-then-global order. */ private notes(): Note[] { const entries = this.callbacks.entries(); const filter = this.effectiveFilter(); const scopes: MemoryScope[] = filter === "all" ? (this.callbacks.trusted() ? ["project", "global"] : ["global"]) : [filter]; const notes: Note[] = []; for (const scope of scopes) { for (const text of entries[scope]) notes.push({ scope, text }); } return notes; } /** The scope an action targets: the filter's scope, or the best default. */ private activeScope(): MemoryScope { if (this.filter === "global") return "global"; if (this.filter === "project" && this.callbacks.trusted()) return "project"; return this.callbacks.trusted() ? "project" : "global"; } /** Index of the selected note in `notes`, re-derived from its key. */ private focusIndex(notes: Note[]): number { if (notes.length === 0) return 0; const found = this.selectedKey ? notes.findIndex((note) => noteKey(note) === this.selectedKey) : -1; return found >= 0 ? found : 0; } /** Move the focus to `index`, recording the note's key. Never repaints. */ private focusIndexNow(index: number, notes: Note[]): void { if (notes.length === 0) { this.selectedKey = undefined; return; } const clamped = Math.min(Math.max(0, index), notes.length - 1); this.selectedKey = noteKey(notes[clamped]); this.cursor.selected = clamped; this.cursor.sync(notes.length); } private cycleFilter(): void { const order: MemoryFilter[] = this.callbacks.trusted() ? ["all", "project", "global"] : ["all", "global"]; const at = order.indexOf(this.effectiveFilter()); this.filter = order[(at + 1) % order.length]; this.selectedKey = undefined; this.requestRender(); } private startEdit(note: Note | undefined): void { if (!note) return; if (note.scope === "project" && !this.callbacks.trusted()) { this.notice = "Project memory is unavailable for an untrusted project."; this.requestRender(); return; } this.mode = "edit"; this.editing = note; this.editorScope = note.scope; this.editor.setText(note.text); this.syncEditorFocus(); this.requestRender(); } private startAdd(): void { this.mode = "add"; this.editing = undefined; this.editorScope = this.activeScope(); this.editor.setText(""); this.syncEditorFocus(); this.requestRender(); } private cancelEditor(): void { this.mode = "list"; this.editing = undefined; this.syncEditorFocus(); this.notice = undefined; this.requestRender(); } private submitEditor(value: string): void { const text = sanitizeLine(value).trim(); const editing = this.editing; const scope = this.editorScope; this.mode = "list"; this.editing = undefined; this.syncEditorFocus(); if (text.length === 0) { this.notice = "A note cannot be blank."; this.requestRender(); return; } if (text.length > MAX_NOTE_CHARS) { this.notice = `A note must be at most ${MAX_NOTE_CHARS} characters.`; this.requestRender(); return; } this.run(async () => { if (editing) { if (text === editing.text) { this.notice = "Unchanged."; return; } await this.callbacks.update(scope, editing.text, text); this.notice = `Updated ${scopeLabel(scope)} note.`; this.selectedKey = noteKey({ scope, text }); return; } const result = await this.callbacks.add(scope, text); if (result.changed) { this.notice = `Added ${scopeLabel(scope)} note.`; this.selectedKey = noteKey({ scope, text }); } else { this.notice = `Already remembered: ${result.existing ?? text}`; } }); } private forgetNote(note: Note): void { // Capture the neighbour before the write so the focus lands on it after. const before = this.notes(); const at = before.findIndex((item) => noteKey(item) === noteKey(note)); const neighbour = before[at + 1] ?? before[at - 1]; this.run(async () => { await this.callbacks.forget(note.scope, note.text); this.notice = `Forgot ${scopeLabel(note.scope)} note.`; this.selectedKey = neighbour ? noteKey(neighbour) : undefined; }); } private reload(): void { this.notice = this.callbacks.refresh() ? "Reloaded memory." : "Memory is already up to date."; this.requestRender(); } /** Run a mutation off the shared chain, then repaint from the new state. */ private run(action: () => Promise): void { this.chain = this.chain .then(action) .catch((error: unknown) => { this.notice = `Memory not saved: ${messageOf(error)}`; }) .then(() => this.requestRender()); } private syncEditorFocus(): void { this.editor.focused = this.componentFocused && (this.mode === "edit" || this.mode === "add"); } private handleConfirm(data: string): void { const note = this.confirm; if (!note) return; if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) { this.confirm = undefined; this.forgetNote(note); return; } if (data === "n" || data === "N" || isCloseKey(data) || data === "q") { this.confirm = undefined; this.requestRender(); } } handleInput(data: string): void { if (this.mode === "edit" || this.mode === "add") { if (isCloseKey(data) || matchesKey(data, Key.escape)) { this.cancelEditor(); return; } this.editor.handleInput(data); this.requestRender(); return; } if (this.confirm) { this.handleConfirm(data); return; } if (isCloseKey(data) || data === "q") { this.onClose(); return; } if (this.notice !== undefined) this.notice = undefined; const notes = this.notes(); this.focusIndexNow(this.focusIndex(notes), notes); if (navigateList(data, this.cursor, notes.length)) { this.selectedKey = notes[this.cursor.selected] ? noteKey(notes[this.cursor.selected]) : undefined; this.requestRender(); return; } // The hint advertises `g`/`G`, so bind them to the ends. if (data === "g" || data === "G") { this.cursor.set(data === "g" ? 0 : notes.length - 1, notes.length); this.selectedKey = notes[this.cursor.selected] ? noteKey(notes[this.cursor.selected]) : undefined; this.requestRender(); return; } const focused = notes[this.cursor.selected]; if (matchesKey(data, Key.tab) || data === "s") this.cycleFilter(); else if (matchesKey(data, Key.enter) || data === "e") this.startEdit(focused); else if (data === "a") this.startAdd(); else if (data === "d") { if (!focused) return; this.confirm = focused; this.requestRender(); } else if (data === "r") this.reload(); } /** Wheel scrolling moves the selection, matching the other list screens. */ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined { if (this.mode !== "list" || this.confirm) return undefined; const notes = this.notes(); const handled = scrollList(event, this.cursor, notes.length); if (handled) this.selectedKey = notes[this.cursor.selected] ? noteKey(notes[this.cursor.selected]) : undefined; return handled; } invalidate(): void { this.editor.invalidate(); } dispose(): void { this.editor.focused = false; } get focused(): boolean { return this.componentFocused; } set focused(value: boolean) { if (this.componentFocused === value) return; this.componentFocused = value; this.syncEditorFocus(); this.requestRender(); } private summary(width: number): string { const entries = this.callbacks.entries(); const filter = this.effectiveFilter(); let text = paint(this.theme, "accent", filter === "all" ? "All" : scopeLabel(filter)); text += paint(this.theme, "meta", ` · ${entries.project.length} project, ${entries.global.length} global`); if (!this.callbacks.trusted()) text += paint(this.theme, "warn", " · project untrusted"); return clip(` ${text}`, width); } /** One note row: selection marker, scope tag, and the sanitized note text. */ private noteRow(note: Note, selected: boolean, width: number): string { const tag = `${paint(this.theme, "meta", scopeLabel(note.scope))} ${paint(this.theme, "faint", "·")}`; return listRow(this.theme, width, { selected, leading: tag, label: sanitizeLine(note.text), labelRole: selected ? "accent" : "label", }); } private confirmPrompt(): string { const note = this.confirm; if (!note) return ""; const label = sanitizeLine(note.text); const clipped = label.length > 40 ? `${label.slice(0, 39)}…` : label; return `Forget ${scopeLabel(note.scope)} note "${clipped}"? y/N`; } private renderEditor(width: number): string[] { const verb = this.mode === "edit" ? "Edit" : "Add"; const scope = scopeLabel(this.editorScope); const pad = width > 1 ? " " : ""; const body: string[] = []; for (const line of this.editor.render(Math.max(1, width - 2))) body.push(`${pad}${line}`); return dockListFrame({ header: titleRule(this.theme, width, `Memory · ${verb} ${scope}`), summary: clip( ` ${paint(this.theme, "meta", this.mode === "edit" ? "Editing the focused note" : `New ${scope} note`)}`, width, ), body, hint: screenHint(this.theme, width, ["Enter save", "Esc cancel"]), }); } render(width: number): string[] { const w = Math.max(1, width); this.syncEditorFocus(); if (this.mode === "edit" || this.mode === "add") return this.renderEditor(w); const notes = this.notes(); this.cursor.visible = fitRows(this.viewportRowsSource, notes.length, CHROME, SCREEN_DEFAULT_ROWS); this.focusIndexNow(this.focusIndex(notes), notes); const body: string[] = []; if (notes.length === 0) { body.push(...emptyState(this.theme, w, { label: "No notes yet. Press a to add one." })); } else { const { start, end } = this.cursor.window(notes.length); for (let index = start; index < end; index++) { body.push(this.noteRow(notes[index], index === this.cursor.selected, w)); } } const hint = this.confirm ? clip(` ${paint(this.theme, "warn", this.confirmPrompt())}`, w) : this.notice !== undefined ? clip(` ${paint(this.theme, "meta", this.notice)}`, w) : screenHint(this.theme, w, ["Esc close", "Enter/e edit", "a add", "d forget", "s/Tab scope", "↑/↓", "g/G"]); return dockListFrame({ header: titleRule(this.theme, w, "Memory"), summary: this.summary(w), body, range: this.cursor.clipped(notes.length) ? formatRange(this.theme, w, { ...this.cursor.window(notes.length), total: notes.length }) : undefined, hint, }); } }