@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.
@@ -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";