@ryan_nookpi/pi-extension-memory-layer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/index.ts +698 -0
- package/inject.ts +49 -0
- package/package.json +42 -0
- package/project-id.ts +77 -0
- package/storage.ts +680 -0
- package/types.ts +72 -0
- package/ui.ts +480 -0
package/types.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
|
|
4
|
+
// ── Memory Scope ─────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export type MemoryScope = "user" | "project";
|
|
7
|
+
|
|
8
|
+
// ── Tool Parameter Schemas ───────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export const RememberParams = Type.Object({
|
|
11
|
+
content: Type.String({
|
|
12
|
+
description: "Content to remember (the fact, rule, or lesson to store in long-term memory)",
|
|
13
|
+
}),
|
|
14
|
+
title: Type.Optional(Type.String({ description: "Short title/summary for the memory (auto-generated if omitted)" })),
|
|
15
|
+
scope: StringEnum(["user", "project"] as const, {
|
|
16
|
+
description:
|
|
17
|
+
"Storage scope. 'user' for personal profile, global preferences, or cross-project rules. " +
|
|
18
|
+
"'project' for repo-specific tech decisions, env, tooling, configs. " +
|
|
19
|
+
"Choose based on whether the information applies globally or only to the current project.",
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const RecallParams = Type.Object({
|
|
24
|
+
query: Type.Optional(
|
|
25
|
+
Type.String({
|
|
26
|
+
description:
|
|
27
|
+
"Search query (keywords or natural language) to find relevant memories. Returns a summary list with IDs.",
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
id: Type.Optional(
|
|
31
|
+
Type.String({ description: "Memory entry ID for detail lookup. Returns the full content of a specific memory." }),
|
|
32
|
+
),
|
|
33
|
+
scope: Type.Optional(
|
|
34
|
+
StringEnum(["user", "project"] as const, {
|
|
35
|
+
description: "Optional scope filter for recall results (user|project).",
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const ForgetParams = Type.Object(
|
|
41
|
+
{
|
|
42
|
+
topic: Type.Optional(
|
|
43
|
+
Type.String({
|
|
44
|
+
description:
|
|
45
|
+
"Topic filename (e.g. 'coding-rules' or 'coding-rules.md'). Optional when title uniquely identifies a single memory.",
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
title: Type.String({
|
|
49
|
+
description:
|
|
50
|
+
"Title of the memory entry to remove. Exact match is preferred; if topic is omitted, it must resolve to a single memory.",
|
|
51
|
+
}),
|
|
52
|
+
scope: Type.Optional(
|
|
53
|
+
StringEnum(["user", "project"] as const, {
|
|
54
|
+
description: "Scope to delete from (user|project). If omitted, searches both and errors on ambiguity.",
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
},
|
|
58
|
+
{ description: "Permanently delete a memory entry. This action is irreversible." },
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
export const MemoryListParams = Type.Object({
|
|
62
|
+
scope: Type.Optional(StringEnum(["user", "project"] as const, { description: "Filter by scope" })),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ── Project ID Resolution (unchanged) ────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
export type ProjectIdBasis = "remote" | "commit" | "path";
|
|
68
|
+
|
|
69
|
+
export interface ProjectIdResult {
|
|
70
|
+
id: string;
|
|
71
|
+
basis: ProjectIdBasis;
|
|
72
|
+
}
|
package/ui.ts
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /memory overlay UI components — Markdown-based memory system.
|
|
3
|
+
* Displays entries as [scope] topic / title.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { DynamicBorder, getMarkdownTheme, type Theme } from "@mariozechner/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
Container,
|
|
9
|
+
type Focusable,
|
|
10
|
+
fuzzyMatch,
|
|
11
|
+
getKeybindings,
|
|
12
|
+
Input,
|
|
13
|
+
Key,
|
|
14
|
+
Markdown,
|
|
15
|
+
matchesKey,
|
|
16
|
+
type SelectItem,
|
|
17
|
+
SelectList,
|
|
18
|
+
Spacer,
|
|
19
|
+
Text,
|
|
20
|
+
type TUI,
|
|
21
|
+
truncateToWidth,
|
|
22
|
+
visibleWidth,
|
|
23
|
+
} from "@mariozechner/pi-tui";
|
|
24
|
+
import type { SearchResult } from "./storage.ts";
|
|
25
|
+
import type { MemoryScope } from "./types.ts";
|
|
26
|
+
|
|
27
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export type MemoryMenuAction = "view" | "viewTopic" | "delete" | "copyContent";
|
|
30
|
+
export type ScopeFilter = "all" | "user" | "project";
|
|
31
|
+
|
|
32
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function scopeBadge(theme: Theme, scope: MemoryScope): string {
|
|
35
|
+
return scope === "user" ? theme.fg("accent", "[user]") : theme.fg("success", "[project]");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildSearchText(entry: SearchResult): string {
|
|
39
|
+
return [entry.scope, entry.topic, entry.title, entry.content, entry.projectId ?? ""].join(" ").toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function filterEntries(entries: SearchResult[], query: string, scopeFilter: ScopeFilter): SearchResult[] {
|
|
43
|
+
let filtered = entries;
|
|
44
|
+
if (scopeFilter !== "all") {
|
|
45
|
+
filtered = filtered.filter((e) => e.scope === scopeFilter);
|
|
46
|
+
}
|
|
47
|
+
const trimmed = query.trim();
|
|
48
|
+
if (!trimmed) return filtered;
|
|
49
|
+
|
|
50
|
+
const tokens = trimmed
|
|
51
|
+
.split(/\s+/)
|
|
52
|
+
.map((t) => t.trim())
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
if (!tokens.length) return filtered;
|
|
55
|
+
|
|
56
|
+
const scored: Array<{ entry: SearchResult; score: number }> = [];
|
|
57
|
+
for (const entry of filtered) {
|
|
58
|
+
const text = buildSearchText(entry);
|
|
59
|
+
let totalScore = 0;
|
|
60
|
+
let matched = true;
|
|
61
|
+
for (const token of tokens) {
|
|
62
|
+
const result = fuzzyMatch(token, text);
|
|
63
|
+
if (!result.matches) {
|
|
64
|
+
matched = false;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
totalScore += result.score;
|
|
68
|
+
}
|
|
69
|
+
if (matched) scored.push({ entry, score: totalScore });
|
|
70
|
+
}
|
|
71
|
+
return scored.sort((a, b) => a.score - b.score).map((s) => s.entry);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Memory Selector Component ────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export class MemorySelectorComponent extends Container implements Focusable {
|
|
77
|
+
private searchInput: Input;
|
|
78
|
+
private listContainer: Container;
|
|
79
|
+
private allEntries: SearchResult[];
|
|
80
|
+
private filteredEntries: SearchResult[];
|
|
81
|
+
private selectedIndex = 0;
|
|
82
|
+
private scopeFilter: ScopeFilter = "all";
|
|
83
|
+
private onSelectCallback: (entry: SearchResult) => void;
|
|
84
|
+
private onCancelCallback: () => void;
|
|
85
|
+
private tui: TUI;
|
|
86
|
+
private theme: Theme;
|
|
87
|
+
private headerText: Text;
|
|
88
|
+
private scopeText: Text;
|
|
89
|
+
private hintText: Text;
|
|
90
|
+
|
|
91
|
+
private _focused = false;
|
|
92
|
+
get focused(): boolean {
|
|
93
|
+
return this._focused;
|
|
94
|
+
}
|
|
95
|
+
set focused(value: boolean) {
|
|
96
|
+
this._focused = value;
|
|
97
|
+
this.searchInput.focused = value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
constructor(
|
|
101
|
+
tui: TUI,
|
|
102
|
+
theme: Theme,
|
|
103
|
+
entries: SearchResult[],
|
|
104
|
+
onSelect: (entry: SearchResult) => void,
|
|
105
|
+
onCancel: () => void,
|
|
106
|
+
initialSearch?: string,
|
|
107
|
+
) {
|
|
108
|
+
super();
|
|
109
|
+
this.tui = tui;
|
|
110
|
+
this.theme = theme;
|
|
111
|
+
this.allEntries = entries;
|
|
112
|
+
this.filteredEntries = entries;
|
|
113
|
+
this.onSelectCallback = onSelect;
|
|
114
|
+
this.onCancelCallback = onCancel;
|
|
115
|
+
|
|
116
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
117
|
+
this.addChild(new Spacer(1));
|
|
118
|
+
|
|
119
|
+
this.headerText = new Text("", 1, 0);
|
|
120
|
+
this.addChild(this.headerText);
|
|
121
|
+
|
|
122
|
+
this.scopeText = new Text("", 1, 0);
|
|
123
|
+
this.addChild(this.scopeText);
|
|
124
|
+
this.addChild(new Spacer(1));
|
|
125
|
+
|
|
126
|
+
this.searchInput = new Input();
|
|
127
|
+
if (initialSearch) this.searchInput.setValue(initialSearch);
|
|
128
|
+
this.searchInput.onSubmit = () => {
|
|
129
|
+
const selected = this.filteredEntries[this.selectedIndex];
|
|
130
|
+
if (selected) this.onSelectCallback(selected);
|
|
131
|
+
};
|
|
132
|
+
this.addChild(this.searchInput);
|
|
133
|
+
this.addChild(new Spacer(1));
|
|
134
|
+
|
|
135
|
+
this.listContainer = new Container();
|
|
136
|
+
this.addChild(this.listContainer);
|
|
137
|
+
this.addChild(new Spacer(1));
|
|
138
|
+
|
|
139
|
+
this.hintText = new Text("", 1, 0);
|
|
140
|
+
this.addChild(this.hintText);
|
|
141
|
+
this.addChild(new Spacer(1));
|
|
142
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
143
|
+
|
|
144
|
+
this.updateHeader();
|
|
145
|
+
this.updateScopeDisplay();
|
|
146
|
+
this.updateHints();
|
|
147
|
+
this.applyFilter();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
setEntries(entries: SearchResult[]): void {
|
|
151
|
+
this.allEntries = entries;
|
|
152
|
+
this.updateHeader();
|
|
153
|
+
this.applyFilter();
|
|
154
|
+
this.tui.requestRender();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private updateHeader(): void {
|
|
158
|
+
const count = this.allEntries.length;
|
|
159
|
+
const topics = new Set(this.allEntries.map((e) => e.topic)).size;
|
|
160
|
+
const title = `Memories (${count} entries, ${topics} topics)`;
|
|
161
|
+
this.headerText.setText(this.theme.fg("accent", this.theme.bold(title)));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private updateScopeDisplay(): void {
|
|
165
|
+
const labels: Record<ScopeFilter, string> = {
|
|
166
|
+
all: "📋 All",
|
|
167
|
+
user: "🌐 User only",
|
|
168
|
+
project: "📁 Project only",
|
|
169
|
+
};
|
|
170
|
+
this.scopeText.setText(
|
|
171
|
+
this.theme.fg("muted", `Filter: ${labels[this.scopeFilter]}`) + this.theme.fg("dim", " (Tab to cycle)"),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private updateHints(): void {
|
|
176
|
+
this.hintText.setText(this.theme.fg("dim", "Type to search • ↑↓ select • Enter actions • Tab scope • Esc close"));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private applyFilter(): void {
|
|
180
|
+
this.filteredEntries = filterEntries(this.allEntries, this.searchInput.getValue(), this.scopeFilter);
|
|
181
|
+
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredEntries.length - 1));
|
|
182
|
+
this.updateList();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private updateList(): void {
|
|
186
|
+
this.listContainer.clear();
|
|
187
|
+
|
|
188
|
+
if (this.filteredEntries.length === 0) {
|
|
189
|
+
this.listContainer.addChild(new Text(this.theme.fg("muted", " No matching memories"), 0, 0));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const maxVisible = 10;
|
|
194
|
+
const startIndex = Math.max(
|
|
195
|
+
0,
|
|
196
|
+
Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredEntries.length - maxVisible),
|
|
197
|
+
);
|
|
198
|
+
const endIndex = Math.min(startIndex + maxVisible, this.filteredEntries.length);
|
|
199
|
+
|
|
200
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
201
|
+
const entry = this.filteredEntries[i];
|
|
202
|
+
if (!entry) continue;
|
|
203
|
+
const isSelected = i === this.selectedIndex;
|
|
204
|
+
|
|
205
|
+
const prefix = isSelected ? this.theme.fg("accent", "→ ") : " ";
|
|
206
|
+
const badge = scopeBadge(this.theme, entry.scope);
|
|
207
|
+
const topicLabel = this.theme.fg("muted", `${entry.topic}/`);
|
|
208
|
+
const titleColor = isSelected ? "accent" : "text";
|
|
209
|
+
const titleText = this.theme.fg(titleColor, entry.title || "(untitled)");
|
|
210
|
+
|
|
211
|
+
this.listContainer.addChild(new Text(`${prefix}${badge} ${topicLabel}${titleText}`, 0, 0));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (startIndex > 0 || endIndex < this.filteredEntries.length) {
|
|
215
|
+
const scrollInfo = this.theme.fg("dim", ` (${this.selectedIndex + 1}/${this.filteredEntries.length})`);
|
|
216
|
+
this.listContainer.addChild(new Text(scrollInfo, 0, 0));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private cycleScope(): void {
|
|
221
|
+
const order: ScopeFilter[] = ["all", "user", "project"];
|
|
222
|
+
const idx = order.indexOf(this.scopeFilter);
|
|
223
|
+
this.scopeFilter = order[(idx + 1) % order.length];
|
|
224
|
+
this.updateScopeDisplay();
|
|
225
|
+
this.applyFilter();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
handleInput(keyData: string): void {
|
|
229
|
+
const kb = getKeybindings();
|
|
230
|
+
if (kb.matches(keyData, "tui.select.up")) {
|
|
231
|
+
if (!this.filteredEntries.length) return;
|
|
232
|
+
this.selectedIndex = this.selectedIndex === 0 ? this.filteredEntries.length - 1 : this.selectedIndex - 1;
|
|
233
|
+
this.updateList();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (kb.matches(keyData, "tui.select.down")) {
|
|
237
|
+
if (!this.filteredEntries.length) return;
|
|
238
|
+
this.selectedIndex = this.selectedIndex === this.filteredEntries.length - 1 ? 0 : this.selectedIndex + 1;
|
|
239
|
+
this.updateList();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (kb.matches(keyData, "tui.select.confirm")) {
|
|
243
|
+
const selected = this.filteredEntries[this.selectedIndex];
|
|
244
|
+
if (selected) this.onSelectCallback(selected);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (kb.matches(keyData, "tui.select.cancel")) {
|
|
248
|
+
this.onCancelCallback();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (matchesKey(keyData, Key.tab)) {
|
|
252
|
+
this.cycleScope();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
this.searchInput.handleInput(keyData);
|
|
256
|
+
this.applyFilter();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
override invalidate(): void {
|
|
260
|
+
super.invalidate();
|
|
261
|
+
this.updateHeader();
|
|
262
|
+
this.updateScopeDisplay();
|
|
263
|
+
this.updateHints();
|
|
264
|
+
this.updateList();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Memory Action Menu Component ─────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
export class MemoryActionMenuComponent extends Container {
|
|
271
|
+
private selectList: SelectList;
|
|
272
|
+
|
|
273
|
+
constructor(theme: Theme, entry: SearchResult, onSelect: (action: MemoryMenuAction) => void, onCancel: () => void) {
|
|
274
|
+
super();
|
|
275
|
+
|
|
276
|
+
const options: SelectItem[] = [
|
|
277
|
+
{ value: "view", label: "View entry", description: "View this memory entry" },
|
|
278
|
+
{ value: "viewTopic", label: "View full topic", description: `View entire ${entry.topic}.md file` },
|
|
279
|
+
{ value: "copyContent", label: "Copy content", description: "Copy to clipboard" },
|
|
280
|
+
{ value: "delete", label: "🗑️ Delete", description: "Permanently delete this entry" },
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
284
|
+
this.addChild(
|
|
285
|
+
new Text(
|
|
286
|
+
`${theme.fg("accent", theme.bold("Actions"))} ${scopeBadge(theme, entry.scope)} ` +
|
|
287
|
+
`${theme.fg("muted", `${entry.topic}/`)}${theme.fg("text", `"${entry.title}"`)}`,
|
|
288
|
+
),
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
this.selectList = new SelectList(options, options.length, {
|
|
292
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
293
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
294
|
+
description: (text) => theme.fg("muted", text),
|
|
295
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
296
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
297
|
+
});
|
|
298
|
+
this.selectList.onSelect = (item) => onSelect(item.value as MemoryMenuAction);
|
|
299
|
+
this.selectList.onCancel = () => onCancel();
|
|
300
|
+
|
|
301
|
+
this.addChild(this.selectList);
|
|
302
|
+
this.addChild(new Text(theme.fg("dim", "Enter confirm • Esc back")));
|
|
303
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
handleInput(keyData: string): void {
|
|
307
|
+
this.selectList.handleInput(keyData);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
override invalidate(): void {
|
|
311
|
+
super.invalidate();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Memory Detail Overlay ────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
export class MemoryDetailOverlayComponent {
|
|
318
|
+
private tui: TUI;
|
|
319
|
+
private theme: Theme;
|
|
320
|
+
private entry: SearchResult;
|
|
321
|
+
private markdown: Markdown;
|
|
322
|
+
private scrollOffset = 0;
|
|
323
|
+
private viewHeight = 0;
|
|
324
|
+
private totalLines = 0;
|
|
325
|
+
private onClose: () => void;
|
|
326
|
+
private _focused = false;
|
|
327
|
+
|
|
328
|
+
get focused(): boolean {
|
|
329
|
+
return this._focused;
|
|
330
|
+
}
|
|
331
|
+
set focused(value: boolean) {
|
|
332
|
+
this._focused = value;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
constructor(tui: TUI, theme: Theme, entry: SearchResult, onClose: () => void) {
|
|
336
|
+
this.tui = tui;
|
|
337
|
+
this.theme = theme;
|
|
338
|
+
this.entry = entry;
|
|
339
|
+
this.onClose = onClose;
|
|
340
|
+
this.markdown = new Markdown(this.buildContent(), 1, 0, getMarkdownTheme());
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private buildContent(): string {
|
|
344
|
+
const { title, content } = this.entry;
|
|
345
|
+
return `**${title}**\n\n${content || "_No content._"}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
handleInput(keyData: string): void {
|
|
349
|
+
const kb = getKeybindings();
|
|
350
|
+
if (kb.matches(keyData, "tui.select.cancel")) {
|
|
351
|
+
this.onClose();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (kb.matches(keyData, "tui.select.up")) {
|
|
355
|
+
this.scroll(-1);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (kb.matches(keyData, "tui.select.down")) {
|
|
359
|
+
this.scroll(1);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (kb.matches(keyData, "tui.select.pageUp")) {
|
|
363
|
+
this.scroll(-this.viewHeight || -1);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (kb.matches(keyData, "tui.select.pageDown")) {
|
|
367
|
+
this.scroll(this.viewHeight || 1);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
render(width: number): string[] {
|
|
373
|
+
const maxH = Math.max(10, Math.floor((this.tui.terminal.rows || 24) * 0.8));
|
|
374
|
+
const headerLines = 3;
|
|
375
|
+
const footerLines = 2;
|
|
376
|
+
const borderLines = 2;
|
|
377
|
+
const innerW = Math.max(10, width - 2);
|
|
378
|
+
const contentH = Math.max(1, maxH - headerLines - footerLines - borderLines);
|
|
379
|
+
|
|
380
|
+
const mdLines = this.markdown.render(innerW);
|
|
381
|
+
this.totalLines = mdLines.length;
|
|
382
|
+
this.viewHeight = contentH;
|
|
383
|
+
const maxScroll = Math.max(0, this.totalLines - contentH);
|
|
384
|
+
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
|
|
385
|
+
|
|
386
|
+
const visible = mdLines.slice(this.scrollOffset, this.scrollOffset + contentH);
|
|
387
|
+
const lines: string[] = [];
|
|
388
|
+
|
|
389
|
+
// Header
|
|
390
|
+
const e = this.entry;
|
|
391
|
+
const titleLine = ` ${e.title} `;
|
|
392
|
+
const tw = visibleWidth(titleLine);
|
|
393
|
+
const leftW = Math.max(0, Math.floor((innerW - tw) / 2));
|
|
394
|
+
const rightW = Math.max(0, innerW - tw - leftW);
|
|
395
|
+
lines.push(
|
|
396
|
+
this.theme.fg("borderMuted", "─".repeat(leftW)) +
|
|
397
|
+
this.theme.fg("accent", titleLine) +
|
|
398
|
+
this.theme.fg("borderMuted", "─".repeat(rightW)),
|
|
399
|
+
);
|
|
400
|
+
lines.push(
|
|
401
|
+
`${scopeBadge(this.theme, e.scope)} ${this.theme.fg("muted", `${e.topic}.md`)}${e.projectId ? this.theme.fg("dim", ` • ${e.projectId}`) : ""}`,
|
|
402
|
+
);
|
|
403
|
+
lines.push("");
|
|
404
|
+
|
|
405
|
+
// Content
|
|
406
|
+
for (const l of visible) lines.push(truncateToWidth(l, innerW));
|
|
407
|
+
while (lines.length < headerLines + contentH) lines.push("");
|
|
408
|
+
|
|
409
|
+
// Footer
|
|
410
|
+
let footer = this.theme.fg("dim", "esc back • ↑↓ scroll");
|
|
411
|
+
if (this.totalLines > this.viewHeight) {
|
|
412
|
+
const start = Math.min(this.totalLines, this.scrollOffset + 1);
|
|
413
|
+
const end = Math.min(this.totalLines, this.scrollOffset + this.viewHeight);
|
|
414
|
+
footer += this.theme.fg("dim", ` ${start}-${end}/${this.totalLines}`);
|
|
415
|
+
}
|
|
416
|
+
lines.push("");
|
|
417
|
+
lines.push(footer);
|
|
418
|
+
|
|
419
|
+
// Frame
|
|
420
|
+
const bc = (t: string) => this.theme.fg("borderMuted", t);
|
|
421
|
+
const top = bc(`┌${"─".repeat(innerW)}┐`);
|
|
422
|
+
const bottom = bc(`└${"─".repeat(innerW)}┘`);
|
|
423
|
+
const framed = lines.map((l) => {
|
|
424
|
+
const tr = truncateToWidth(l, innerW);
|
|
425
|
+
const pad = Math.max(0, innerW - visibleWidth(tr));
|
|
426
|
+
return `${bc("│")}${tr}${" ".repeat(pad)}${bc("│")}`;
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
return [top, ...framed, bottom].map((l) => truncateToWidth(l, width));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
invalidate(): void {
|
|
433
|
+
this.markdown = new Markdown(this.buildContent(), 1, 0, getMarkdownTheme());
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private scroll(delta: number): void {
|
|
437
|
+
const max = Math.max(0, this.totalLines - this.viewHeight);
|
|
438
|
+
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset + delta, max));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── Delete Confirmation Component ────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
export class MemoryDeleteConfirmComponent extends Container {
|
|
445
|
+
private selectList: SelectList;
|
|
446
|
+
|
|
447
|
+
constructor(theme: Theme, message: string, onConfirm: (confirmed: boolean) => void) {
|
|
448
|
+
super();
|
|
449
|
+
|
|
450
|
+
const options: SelectItem[] = [
|
|
451
|
+
{ value: "yes", label: "Yes, delete" },
|
|
452
|
+
{ value: "no", label: "No, keep it" },
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("error", s)));
|
|
456
|
+
this.addChild(new Text(theme.fg("error", message)));
|
|
457
|
+
|
|
458
|
+
this.selectList = new SelectList(options, options.length, {
|
|
459
|
+
selectedPrefix: (text) => theme.fg("error", text),
|
|
460
|
+
selectedText: (text) => theme.fg("error", text),
|
|
461
|
+
description: (text) => theme.fg("muted", text),
|
|
462
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
463
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
464
|
+
});
|
|
465
|
+
this.selectList.onSelect = (item) => onConfirm(item.value === "yes");
|
|
466
|
+
this.selectList.onCancel = () => onConfirm(false);
|
|
467
|
+
|
|
468
|
+
this.addChild(this.selectList);
|
|
469
|
+
this.addChild(new Text(theme.fg("dim", "Enter confirm • Esc cancel")));
|
|
470
|
+
this.addChild(new DynamicBorder((s: string) => theme.fg("error", s)));
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
handleInput(keyData: string): void {
|
|
474
|
+
this.selectList.handleInput(keyData);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
override invalidate(): void {
|
|
478
|
+
super.invalidate();
|
|
479
|
+
}
|
|
480
|
+
}
|