@vs4vijay/piverse 0.1.0 → 0.6.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/.agents/skills/pi-extension/SKILL.md +229 -0
- package/.github/workflows/ci.yml +18 -0
- package/.github/workflows/release.yml +106 -0
- package/LICENSE +21 -0
- package/README.md +70 -27
- package/extensions/pi-notes/README.md +152 -0
- package/extensions/pi-notes/package.json +31 -0
- package/extensions/pi-notes/plan.md +151 -0
- package/extensions/pi-notes/src/index.ts +369 -0
- package/extensions/pi-notes/src/store.ts +229 -0
- package/extensions/pi-notes/src/tui.ts +426 -0
- package/extensions/pi-notes/src/types.ts +60 -0
- package/extensions/pi-queue/README.md +178 -0
- package/extensions/pi-queue/package.json +14 -1
- package/extensions/pi-queue/plan.md +79 -0
- package/extensions/pi-queue/src/index.ts +136 -28
- package/package.json +21 -5
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-notes store — load/save/CRUD for notes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { promises as fs } from "fs";
|
|
6
|
+
import { join, dirname } from "path";
|
|
7
|
+
import type { Note, NotesState } from "./types.js";
|
|
8
|
+
import { NOTES_DIR, NOTES_FILE } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export class NoteStore {
|
|
11
|
+
private cwd: string;
|
|
12
|
+
private notesPath: string;
|
|
13
|
+
private cache: Note[] | null = null;
|
|
14
|
+
|
|
15
|
+
constructor(cwd: string = process.cwd()) {
|
|
16
|
+
this.cwd = cwd;
|
|
17
|
+
this.notesPath = join(cwd, NOTES_DIR, NOTES_FILE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async ensureDir(): Promise<void> {
|
|
21
|
+
const dir = dirname(this.notesPath);
|
|
22
|
+
await fs.mkdir(dir, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async load(): Promise<Note[]> {
|
|
26
|
+
if (this.cache) return this.cache;
|
|
27
|
+
|
|
28
|
+
await this.ensureDir();
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const data = await fs.readFile(this.notesPath, "utf-8");
|
|
32
|
+
const parsed = JSON.parse(data) as NotesState;
|
|
33
|
+
this.cache = parsed.notes ?? [];
|
|
34
|
+
} catch (err: any) {
|
|
35
|
+
if (err.code === "ENOENT") {
|
|
36
|
+
this.cache = [];
|
|
37
|
+
} else {
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return this.cache;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async save(notes: Note[]): Promise<void> {
|
|
46
|
+
await this.ensureDir();
|
|
47
|
+
|
|
48
|
+
const state: NotesState = {
|
|
49
|
+
notes,
|
|
50
|
+
version: 1,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Atomic write: write to temp then rename
|
|
54
|
+
const tempPath = this.notesPath + ".tmp";
|
|
55
|
+
await fs.writeFile(tempPath, JSON.stringify(state, null, 2), "utf-8");
|
|
56
|
+
await fs.rename(tempPath, this.notesPath);
|
|
57
|
+
|
|
58
|
+
this.cache = notes;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async getAll(): Promise<Note[]> {
|
|
62
|
+
return this.load();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async getById(id: string): Promise<Note | undefined> {
|
|
66
|
+
const notes = await this.load();
|
|
67
|
+
return notes.find((n) => n.id === id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async create(title: string, content: string = ""): Promise<Note> {
|
|
71
|
+
const notes = await this.load();
|
|
72
|
+
const now = new Date().toISOString();
|
|
73
|
+
|
|
74
|
+
const note: Note = {
|
|
75
|
+
id: crypto.randomUUID(),
|
|
76
|
+
title,
|
|
77
|
+
content,
|
|
78
|
+
createdAt: now,
|
|
79
|
+
updatedAt: now,
|
|
80
|
+
tags: [],
|
|
81
|
+
pinned: false,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Pinned notes first, then by updatedAt desc
|
|
85
|
+
notes.unshift(note);
|
|
86
|
+
await this.save(notes);
|
|
87
|
+
|
|
88
|
+
return note;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async update(id: string, updates: Partial<Pick<Note, "title" | "content" | "tags" | "pinned">>): Promise<Note | undefined> {
|
|
92
|
+
const notes = await this.load();
|
|
93
|
+
const index = notes.findIndex((n) => n.id === id);
|
|
94
|
+
|
|
95
|
+
if (index === -1) return undefined;
|
|
96
|
+
|
|
97
|
+
const updated: Note = {
|
|
98
|
+
...notes[index],
|
|
99
|
+
...updates,
|
|
100
|
+
updatedAt: new Date().toISOString(),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
notes[index] = updated;
|
|
104
|
+
|
|
105
|
+
// Re-sort: pinned first, then updatedAt desc
|
|
106
|
+
notes.sort((a, b) => {
|
|
107
|
+
if (a.pinned && !b.pinned) return -1;
|
|
108
|
+
if (!a.pinned && b.pinned) return 1;
|
|
109
|
+
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await this.save(notes);
|
|
113
|
+
return updated;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async delete(id: string): Promise<boolean> {
|
|
117
|
+
const notes = await this.load();
|
|
118
|
+
const filtered = notes.filter((n) => n.id !== id);
|
|
119
|
+
|
|
120
|
+
if (filtered.length === notes.length) return false;
|
|
121
|
+
|
|
122
|
+
await this.save(filtered);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async search(query: string): Promise<Note[]> {
|
|
127
|
+
const notes = await this.load();
|
|
128
|
+
const lower = query.toLowerCase();
|
|
129
|
+
return notes.filter(
|
|
130
|
+
(n) => n.title.toLowerCase().includes(lower) || n.content.toLowerCase().includes(lower)
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async exportTo(filePath: string): Promise<number> {
|
|
135
|
+
const state: NotesState = {
|
|
136
|
+
notes: await this.getAll(),
|
|
137
|
+
version: 1,
|
|
138
|
+
};
|
|
139
|
+
const dir = dirname(filePath);
|
|
140
|
+
await fs.mkdir(dir, { recursive: true });
|
|
141
|
+
const tempPath = filePath + ".tmp";
|
|
142
|
+
await fs.writeFile(tempPath, JSON.stringify(state, null, 2), "utf-8");
|
|
143
|
+
await fs.rename(tempPath, filePath);
|
|
144
|
+
return state.notes.length;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async importFrom(filePath: string): Promise<{ imported: number; skipped: number }> {
|
|
148
|
+
const data = await fs.readFile(filePath, "utf-8");
|
|
149
|
+
const parsed = JSON.parse(data) as unknown;
|
|
150
|
+
const incoming = parseNotesState(parsed);
|
|
151
|
+
|
|
152
|
+
const current = await this.getAll();
|
|
153
|
+
const existingIds = new Set(current.map((n) => n.id));
|
|
154
|
+
const merged = [...current];
|
|
155
|
+
let imported = 0;
|
|
156
|
+
|
|
157
|
+
for (const note of incoming) {
|
|
158
|
+
if (existingIds.has(note.id)) continue;
|
|
159
|
+
merged.push(note);
|
|
160
|
+
existingIds.add(note.id);
|
|
161
|
+
imported++;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
merged.sort((a, b) => {
|
|
165
|
+
if (a.pinned && !b.pinned) return -1;
|
|
166
|
+
if (!a.pinned && b.pinned) return 1;
|
|
167
|
+
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await this.save(merged);
|
|
171
|
+
return { imported, skipped: incoming.length - imported };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parseNotesState(value: unknown): Note[] {
|
|
176
|
+
if (!value || typeof value !== "object" || !Array.isArray((value as { notes?: unknown }).notes)) {
|
|
177
|
+
throw new Error('Invalid notes file: expected an object like { "notes": [...] }');
|
|
178
|
+
}
|
|
179
|
+
const notes = (value as { notes: unknown[] }).notes;
|
|
180
|
+
return notes.map((n, i) => parseNote(n, i));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseNote(value: unknown, index: number): Note {
|
|
184
|
+
if (!value || typeof value !== "object") {
|
|
185
|
+
throw new Error(`Invalid note at index ${index}: expected an object`);
|
|
186
|
+
}
|
|
187
|
+
const n = value as Record<string, unknown>;
|
|
188
|
+
if (
|
|
189
|
+
typeof n.id !== "string" ||
|
|
190
|
+
typeof n.title !== "string" ||
|
|
191
|
+
typeof n.createdAt !== "string" ||
|
|
192
|
+
typeof n.updatedAt !== "string"
|
|
193
|
+
) {
|
|
194
|
+
throw new Error(`Invalid note at index ${index}: missing id, title, createdAt, or updatedAt`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const note: Note = {
|
|
198
|
+
id: n.id,
|
|
199
|
+
title: n.title,
|
|
200
|
+
content: typeof n.content === "string" ? n.content : "",
|
|
201
|
+
createdAt: n.createdAt,
|
|
202
|
+
updatedAt: n.updatedAt,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
if (Array.isArray(n.tags)) {
|
|
206
|
+
const tags = n.tags.filter((t): t is string => typeof t === "string");
|
|
207
|
+
if (tags.length > 0) note.tags = tags;
|
|
208
|
+
}
|
|
209
|
+
if (typeof n.pinned === "boolean") note.pinned = n.pinned;
|
|
210
|
+
|
|
211
|
+
return note;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Per-cwd cache so multi-project sessions don't share a single store instance
|
|
215
|
+
const stores = new Map<string, NoteStore>();
|
|
216
|
+
|
|
217
|
+
export function getNoteStore(cwd?: string): NoteStore {
|
|
218
|
+
const key = cwd ?? process.cwd();
|
|
219
|
+
let store = stores.get(key);
|
|
220
|
+
if (!store) {
|
|
221
|
+
store = new NoteStore(key);
|
|
222
|
+
stores.set(key, store);
|
|
223
|
+
}
|
|
224
|
+
return store;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function resetNoteStore(): void {
|
|
228
|
+
stores.clear();
|
|
229
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-notes TUI — interactive note management
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { VStack, HStack, Box, Text, ScrollView, Markdown, SelectList, type SelectItem, type SelectListTheme, type MarkdownTheme } from "@earendil-works/pi-tui";
|
|
6
|
+
import type { Component, Focusable } from "@earendil-works/pi-tui";
|
|
7
|
+
import { Theme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { Note } from "./types.js";
|
|
9
|
+
|
|
10
|
+
interface ListItem {
|
|
11
|
+
note: Note;
|
|
12
|
+
display: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type ViewMode = "list" | "view" | "editor" | "confirm" | "search";
|
|
16
|
+
|
|
17
|
+
export class NotesTUI extends VStack implements Focusable {
|
|
18
|
+
private mode: ViewMode = "list";
|
|
19
|
+
private notes: Note[] = [];
|
|
20
|
+
private filteredNotes: ListItem[] = [];
|
|
21
|
+
private searchQuery = "";
|
|
22
|
+
private listSelect: SelectList;
|
|
23
|
+
private previewMarkdown: Markdown;
|
|
24
|
+
private statusText: Text;
|
|
25
|
+
private headerText: Text;
|
|
26
|
+
private theme: Theme;
|
|
27
|
+
private onClose: () => void;
|
|
28
|
+
private onExternalEdit: (note?: Note) => Promise<Note[] | undefined>;
|
|
29
|
+
private onDelete: (id: string) => Promise<Note[]>;
|
|
30
|
+
private onTogglePin: (note: Note) => Promise<Note[]>;
|
|
31
|
+
private editingNote: Note | null = null;
|
|
32
|
+
private confirmAction: "delete" | "cancel" | null = null;
|
|
33
|
+
private confirmNoteId: string | null = null;
|
|
34
|
+
private listBox: Box;
|
|
35
|
+
private previewBox: Box;
|
|
36
|
+
private headerBox: Box;
|
|
37
|
+
private statusBox: Box;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
initialNotes: Note[],
|
|
41
|
+
theme: Theme,
|
|
42
|
+
onClose: () => void,
|
|
43
|
+
onExternalEdit: (note?: Note) => Promise<Note[] | undefined>,
|
|
44
|
+
onDelete: (id: string) => Promise<Note[]>,
|
|
45
|
+
onTogglePin: (note: Note) => Promise<Note[]>
|
|
46
|
+
) {
|
|
47
|
+
super([], { gap: 0 });
|
|
48
|
+
this.theme = theme;
|
|
49
|
+
this.notes = initialNotes;
|
|
50
|
+
this.onClose = onClose;
|
|
51
|
+
this.onExternalEdit = onExternalEdit;
|
|
52
|
+
this.onDelete = onDelete;
|
|
53
|
+
this.onTogglePin = onTogglePin;
|
|
54
|
+
this.filteredNotes = this.buildListItems(this.notes);
|
|
55
|
+
|
|
56
|
+
// Header
|
|
57
|
+
this.headerText = new Text("Pi Notes [n] new [/] search [q] quit");
|
|
58
|
+
this.headerBox = new Box(1, 0);
|
|
59
|
+
this.headerBox.addChild(this.headerText);
|
|
60
|
+
|
|
61
|
+
// Status line
|
|
62
|
+
this.statusText = new Text(this.buildStatusText());
|
|
63
|
+
this.statusBox = new Box(1, 0);
|
|
64
|
+
this.statusBox.addChild(this.statusText);
|
|
65
|
+
|
|
66
|
+
// List selector - use SelectItem type from pi-tui
|
|
67
|
+
const selectItems: SelectItem[] = this.filteredNotes.map(item => ({
|
|
68
|
+
value: item.note.id,
|
|
69
|
+
label: item.display,
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
this.listSelect = new SelectList(selectItems, 20, this.getSelectTheme());
|
|
73
|
+
|
|
74
|
+
// Preview markdown
|
|
75
|
+
this.previewMarkdown = new Markdown("", 0, 0, this.getMarkdownTheme(theme));
|
|
76
|
+
|
|
77
|
+
// Layout: Header | List (left) + Preview (right) | Status
|
|
78
|
+
const listScrollView = new ScrollView(this.listSelect, { scrollbar: "auto" });
|
|
79
|
+
this.listBox = new Box(0, 0);
|
|
80
|
+
this.listBox.addChild(listScrollView);
|
|
81
|
+
|
|
82
|
+
const previewScrollView = new ScrollView(this.previewMarkdown, { scrollbar: "auto" });
|
|
83
|
+
this.previewBox = new Box(0, 0);
|
|
84
|
+
this.previewBox.addChild(previewScrollView);
|
|
85
|
+
|
|
86
|
+
const mainSplit = new HStack([this.listBox, this.previewBox], { gap: 1 });
|
|
87
|
+
|
|
88
|
+
this.addChild(this.headerBox);
|
|
89
|
+
this.addChild(mainSplit);
|
|
90
|
+
this.addChild(this.statusBox);
|
|
91
|
+
|
|
92
|
+
// Listen for selection changes
|
|
93
|
+
this.listSelect.onSelectionChange = (item) => this.onListSelect(item);
|
|
94
|
+
this.updatePreview();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private getSelectTheme(): SelectListTheme {
|
|
98
|
+
return {
|
|
99
|
+
selectedPrefix: (text) => `\x1b[7m ${text} \x1b[0m`,
|
|
100
|
+
selectedText: (text) => `\x1b[7m${text}\x1b[0m`,
|
|
101
|
+
description: (text) => `\x1b[90m${text}\x1b[0m`,
|
|
102
|
+
scrollInfo: (text) => `\x1b[90m${text}\x1b[0m`,
|
|
103
|
+
noMatch: (text) => `\x1b[31m${text}\x1b[0m`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private getMarkdownTheme(theme: Theme): MarkdownTheme {
|
|
108
|
+
const fg = (color: string) => (text: string) => theme.fg(color as any, text);
|
|
109
|
+
return {
|
|
110
|
+
heading: fg("accent"),
|
|
111
|
+
link: fg("accent"),
|
|
112
|
+
linkUrl: fg("accent"),
|
|
113
|
+
code: fg("foreground"),
|
|
114
|
+
codeBlock: fg("foreground"),
|
|
115
|
+
codeBlockBorder: fg("border"),
|
|
116
|
+
quote: fg("muted"),
|
|
117
|
+
quoteBorder: fg("border"),
|
|
118
|
+
hr: fg("border"),
|
|
119
|
+
listBullet: fg("muted"),
|
|
120
|
+
bold: (text) => `\x1b[1m${text}\x1b[0m`,
|
|
121
|
+
italic: (text) => `\x1b[3m${text}\x1b[0m`,
|
|
122
|
+
strikethrough: (text) => `\x1b[9m${text}\x1b[0m`,
|
|
123
|
+
underline: (text) => `\x1b[4m${text}\x1b[0m`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private buildListItems(notes: Note[]): ListItem[] {
|
|
128
|
+
return notes.map((note) => {
|
|
129
|
+
const preview = note.content.slice(0, 60).replace(/\n/g, " ");
|
|
130
|
+
const pin = note.pinned ? "📌 " : "";
|
|
131
|
+
const tags = note.tags?.length ? ` [${note.tags.join(", ")}]` : "";
|
|
132
|
+
return {
|
|
133
|
+
note,
|
|
134
|
+
display: `${pin}${note.title}${tags}\n ${preview}`,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private buildStatusText(): string {
|
|
140
|
+
const total = this.notes.length;
|
|
141
|
+
const filtered = this.filteredNotes.length;
|
|
142
|
+
const pinned = this.notes.filter((n) => n.pinned).length;
|
|
143
|
+
const mode = this.mode === "list" ? "LIST" : this.mode.toUpperCase();
|
|
144
|
+
const search = this.searchQuery ? ` | search: "${this.searchQuery}"` : "";
|
|
145
|
+
return `${mode} | ${filtered}/${total} notes${pinned ? ` | ${pinned} pinned` : ""}${search}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private onListSelect(item: SelectItem | undefined): void {
|
|
149
|
+
if (item) {
|
|
150
|
+
const note = this.filteredNotes.find(n => n.note.id === item.value)?.note;
|
|
151
|
+
if (note) this.updatePreview(note);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private updatePreview(note?: Note): void {
|
|
156
|
+
const target = note ?? this.getSelectedNote();
|
|
157
|
+
if (target) {
|
|
158
|
+
this.previewMarkdown.setText(target.content || "*(empty)*");
|
|
159
|
+
} else {
|
|
160
|
+
this.previewMarkdown.setText("*(no notes)*");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private getSelectedNote(): Note | null {
|
|
165
|
+
const selected = this.listSelect.getSelectedItem();
|
|
166
|
+
if (!selected) return null;
|
|
167
|
+
return this.filteredNotes.find(n => n.note.id === selected.value)?.note ?? null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private refreshList(): void {
|
|
171
|
+
this.filteredNotes = this.buildListItems(this.applyFilter(this.notes));
|
|
172
|
+
const selectItems: SelectItem[] = this.filteredNotes.map(item => ({
|
|
173
|
+
value: item.note.id,
|
|
174
|
+
label: item.display,
|
|
175
|
+
}));
|
|
176
|
+
// Replace SelectList - need to recreate the list
|
|
177
|
+
this.listSelect = new SelectList(selectItems, 20, this.getSelectTheme());
|
|
178
|
+
// Update list box child
|
|
179
|
+
this.listBox.clear();
|
|
180
|
+
this.listBox.addChild(new ScrollView(this.listSelect, { scrollbar: "auto" }));
|
|
181
|
+
this.listSelect.onSelectionChange = (item) => this.onListSelect(item);
|
|
182
|
+
this.statusText.setText(this.buildStatusText());
|
|
183
|
+
this.updatePreview();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private applyFilter(notes: Note[]): Note[] {
|
|
187
|
+
if (!this.searchQuery) return notes;
|
|
188
|
+
const q = this.searchQuery.toLowerCase();
|
|
189
|
+
return notes.filter(
|
|
190
|
+
(n) => n.title.toLowerCase().includes(q) || n.content.toLowerCase().includes(q)
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Focusable implementation
|
|
195
|
+
private _focused = false;
|
|
196
|
+
|
|
197
|
+
set focused(value: boolean) {
|
|
198
|
+
this._focused = value;
|
|
199
|
+
// SelectList doesn't have focused property
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
get focused(): boolean {
|
|
203
|
+
return this._focused;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
handleInput(keyData: string): void {
|
|
207
|
+
if (this.mode === "search") {
|
|
208
|
+
this.handleSearchInput(keyData);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Global keys
|
|
213
|
+
if (keyData === "q" || keyData === "Escape") {
|
|
214
|
+
if (this.mode === "list") {
|
|
215
|
+
this.onClose();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
this.exitMode();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Mode-specific handling
|
|
223
|
+
switch (this.mode) {
|
|
224
|
+
case "list":
|
|
225
|
+
this.handleListInput(keyData);
|
|
226
|
+
break;
|
|
227
|
+
case "view":
|
|
228
|
+
this.handleViewInput(keyData);
|
|
229
|
+
break;
|
|
230
|
+
case "confirm":
|
|
231
|
+
this.handleConfirmInput(keyData);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private handleListInput(key: string): void {
|
|
237
|
+
switch (key) {
|
|
238
|
+
case "n":
|
|
239
|
+
this.openEditor();
|
|
240
|
+
break;
|
|
241
|
+
case "e":
|
|
242
|
+
this.editSelected();
|
|
243
|
+
break;
|
|
244
|
+
case "d":
|
|
245
|
+
this.confirmDelete();
|
|
246
|
+
break;
|
|
247
|
+
case "p":
|
|
248
|
+
this.togglePin();
|
|
249
|
+
break;
|
|
250
|
+
case "/":
|
|
251
|
+
this.startSearch();
|
|
252
|
+
break;
|
|
253
|
+
case "Enter":
|
|
254
|
+
this.enterViewMode();
|
|
255
|
+
break;
|
|
256
|
+
case "ArrowUp":
|
|
257
|
+
case "k":
|
|
258
|
+
this.listSelect.handleInput("ArrowUp");
|
|
259
|
+
break;
|
|
260
|
+
case "ArrowDown":
|
|
261
|
+
case "j":
|
|
262
|
+
this.listSelect.handleInput("ArrowDown");
|
|
263
|
+
break;
|
|
264
|
+
case "PageUp":
|
|
265
|
+
this.listSelect.handleInput("PageUp");
|
|
266
|
+
break;
|
|
267
|
+
case "PageDown":
|
|
268
|
+
this.listSelect.handleInput("PageDown");
|
|
269
|
+
break;
|
|
270
|
+
case "Home":
|
|
271
|
+
this.listSelect.handleInput("Home");
|
|
272
|
+
break;
|
|
273
|
+
case "End":
|
|
274
|
+
this.listSelect.handleInput("End");
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private handleViewInput(key: string): void {
|
|
280
|
+
switch (key) {
|
|
281
|
+
case "e":
|
|
282
|
+
this.editSelected();
|
|
283
|
+
this.exitMode();
|
|
284
|
+
break;
|
|
285
|
+
case "q":
|
|
286
|
+
case "Escape":
|
|
287
|
+
this.exitMode();
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private handleConfirmInput(key: string): void {
|
|
293
|
+
if (key === "y" || key === "Y") {
|
|
294
|
+
if (this.confirmAction === "delete" && this.confirmNoteId) {
|
|
295
|
+
const id = this.confirmNoteId;
|
|
296
|
+
this.onDelete(id).then((notes) => {
|
|
297
|
+
this.notes = notes;
|
|
298
|
+
this.refreshList();
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
this.exitConfirmMode();
|
|
302
|
+
} else if (key === "n" || key === "N" || key === "Escape") {
|
|
303
|
+
this.exitConfirmMode();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private handleSearchInput(key: string): void {
|
|
308
|
+
if (key === "Escape" || key === "Enter") {
|
|
309
|
+
this.endSearch();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (key === "Backspace" || key === "backspace") {
|
|
313
|
+
this.searchQuery = this.searchQuery.slice(0, -1);
|
|
314
|
+
} else if (!isPrintableSearchChar(key)) {
|
|
315
|
+
return;
|
|
316
|
+
} else {
|
|
317
|
+
this.searchQuery += key;
|
|
318
|
+
}
|
|
319
|
+
this.updateSearchHeader();
|
|
320
|
+
this.statusText.setText(this.buildStatusText());
|
|
321
|
+
this.refreshList();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private enterViewMode(): void {
|
|
325
|
+
const note = this.getSelectedNote();
|
|
326
|
+
if (note) {
|
|
327
|
+
this.mode = "view";
|
|
328
|
+
this.headerText.setText(`Pi Notes — ${note.title} [e] edit [q] back`);
|
|
329
|
+
this.statusText.setText(this.buildStatusText());
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private exitMode(): void {
|
|
334
|
+
this.mode = "list";
|
|
335
|
+
this.headerText.setText("Pi Notes [n] new [/] search [q] quit");
|
|
336
|
+
this.statusText.setText(this.buildStatusText());
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private exitConfirmMode(): void {
|
|
340
|
+
this.mode = "list";
|
|
341
|
+
this.confirmAction = null;
|
|
342
|
+
this.confirmNoteId = null;
|
|
343
|
+
this.headerText.setText("Pi Notes [n] new [/] search [q] quit");
|
|
344
|
+
this.statusText.setText(this.buildStatusText());
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private startSearch(): void {
|
|
348
|
+
this.mode = "search";
|
|
349
|
+
this.updateSearchHeader();
|
|
350
|
+
this.statusText.setText(this.buildStatusText());
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private endSearch(): void {
|
|
354
|
+
this.mode = "list";
|
|
355
|
+
this.headerText.setText("Pi Notes [n] new [/] search [q] quit");
|
|
356
|
+
this.statusText.setText(this.buildStatusText());
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private updateSearchHeader(): void {
|
|
360
|
+
this.headerText.setText(`Pi Notes — Search: ${this.searchQuery || ""} [Esc] done`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private openEditor(note?: Note): void {
|
|
364
|
+
this.mode = "editor";
|
|
365
|
+
this.editingNote = note ?? null;
|
|
366
|
+
this.headerText.setText(note ? `Pi Notes — Edit: ${note.title} [Esc] cancel` : "Pi Notes — New Note [Esc] cancel");
|
|
367
|
+
this.statusText.setText("Editing... use external editor");
|
|
368
|
+
this.requestExternalEdit(note);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private editSelected(): void {
|
|
372
|
+
const note = this.getSelectedNote();
|
|
373
|
+
if (note) {
|
|
374
|
+
this.openEditor(note);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private confirmDelete(): void {
|
|
379
|
+
const note = this.getSelectedNote();
|
|
380
|
+
if (note) {
|
|
381
|
+
this.mode = "confirm";
|
|
382
|
+
this.confirmAction = "delete";
|
|
383
|
+
this.confirmNoteId = note.id;
|
|
384
|
+
this.headerText.setText(`Delete "${note.title}"? [y] yes [n] no`);
|
|
385
|
+
this.statusText.setText("Confirm deletion");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private togglePin(): void {
|
|
390
|
+
const note = this.getSelectedNote();
|
|
391
|
+
if (note) {
|
|
392
|
+
this.onTogglePin(note).then((notes) => {
|
|
393
|
+
this.notes = notes;
|
|
394
|
+
this.refreshList();
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private async requestExternalEdit(note?: Note): Promise<void> {
|
|
400
|
+
const updated = await this.onExternalEdit(note);
|
|
401
|
+
this.editingNote = null;
|
|
402
|
+
if (updated) {
|
|
403
|
+
this.notes = updated;
|
|
404
|
+
this.refreshList();
|
|
405
|
+
}
|
|
406
|
+
this.exitMode();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function isPrintableSearchChar(key: string): boolean {
|
|
411
|
+
if (key.length !== 1) return false;
|
|
412
|
+
const code = key.charCodeAt(0);
|
|
413
|
+
return code >= 32 && code !== 127;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Factory function for ctx.ui.custom()
|
|
417
|
+
export function createNotesTUI(
|
|
418
|
+
notes: Note[],
|
|
419
|
+
theme: Theme,
|
|
420
|
+
onClose: () => void,
|
|
421
|
+
onExternalEdit: (note?: Note) => Promise<Note[] | undefined>,
|
|
422
|
+
onDelete: (id: string) => Promise<Note[]>,
|
|
423
|
+
onTogglePin: (note: Note) => Promise<Note[]>
|
|
424
|
+
): Component & Focusable {
|
|
425
|
+
return new NotesTUI(notes, theme, onClose, onExternalEdit, onDelete, onTogglePin);
|
|
426
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-notes type definitions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface Note {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
content: string;
|
|
9
|
+
createdAt: string; // ISO 8601
|
|
10
|
+
updatedAt: string; // ISO 8601
|
|
11
|
+
tags?: string[];
|
|
12
|
+
pinned?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface NotesState {
|
|
16
|
+
notes: Note[];
|
|
17
|
+
version: number; // for future migrations
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Subcommand = "list" | "show" | "rm" | "edit" | "search" | "tag" | "untag" | "pin" | "unpin" | "export" | "import" | "";
|
|
21
|
+
|
|
22
|
+
export interface ParsedCommand {
|
|
23
|
+
subcommand: Subcommand;
|
|
24
|
+
args: string[]; // remaining arguments after subcommand
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseNotesCommand(input: string): ParsedCommand {
|
|
28
|
+
const trimmed = input.trim();
|
|
29
|
+
if (!trimmed) {
|
|
30
|
+
return { subcommand: "", args: [] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const parts = trimmed.split(/\s+/);
|
|
34
|
+
const first = parts[0];
|
|
35
|
+
|
|
36
|
+
const subcommands: Subcommand[] = [
|
|
37
|
+
"list",
|
|
38
|
+
"show",
|
|
39
|
+
"rm",
|
|
40
|
+
"edit",
|
|
41
|
+
"search",
|
|
42
|
+
"tag",
|
|
43
|
+
"untag",
|
|
44
|
+
"pin",
|
|
45
|
+
"unpin",
|
|
46
|
+
"export",
|
|
47
|
+
"import",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
if (subcommands.includes(first as Subcommand)) {
|
|
51
|
+
return { subcommand: first as Subcommand, args: parts.slice(1) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// No recognized subcommand — treat entire input as title for quick-add
|
|
55
|
+
return { subcommand: "", args: [trimmed] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const NOTES_DIR = ".pi/notes";
|
|
59
|
+
export const NOTES_FILE = "notes.json";
|
|
60
|
+
export const TEMPLATES_DIR = "templates";
|