depth-first-thinking 2.0.7 → 2.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/package.json +5 -5
- package/src/commands/delete.ts +53 -0
- package/src/commands/list.ts +19 -0
- package/src/commands/new.ts +47 -0
- package/src/commands/open.ts +38 -0
- package/src/commands/tree.ts +38 -0
- package/src/commands/update.ts +89 -0
- package/src/data/operations.test.ts +284 -0
- package/src/data/operations.ts +111 -0
- package/src/data/storage.ts +227 -0
- package/src/data/types.ts +48 -0
- package/src/index.ts +123 -0
- package/src/tui/app.ts +717 -0
- package/src/tui/navigation.ts +143 -0
- package/src/utils/formatting.test.ts +30 -0
- package/src/utils/formatting.ts +10 -0
- package/src/utils/platform.test.ts +30 -0
- package/src/utils/platform.ts +21 -0
- package/src/utils/validation.test.ts +93 -0
- package/src/utils/validation.ts +74 -0
- package/dist/dft +0 -0
package/src/tui/app.ts
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
import { writeSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
BoxRenderable,
|
|
4
|
+
type CliRenderer,
|
|
5
|
+
type KeyEvent,
|
|
6
|
+
TextAttributes,
|
|
7
|
+
TextRenderable,
|
|
8
|
+
createCliRenderer,
|
|
9
|
+
} from "@opentui/core";
|
|
10
|
+
import {
|
|
11
|
+
addChildNode,
|
|
12
|
+
countDescendants,
|
|
13
|
+
deleteNode,
|
|
14
|
+
editNodeTitle,
|
|
15
|
+
findNode,
|
|
16
|
+
toggleNodeStatus,
|
|
17
|
+
} from "../data/operations";
|
|
18
|
+
import { saveProject } from "../data/storage";
|
|
19
|
+
import type { AppState, ModalState, Node, Project } from "../data/types";
|
|
20
|
+
import { truncate } from "../utils/formatting";
|
|
21
|
+
import { validateTitle } from "../utils/validation";
|
|
22
|
+
import {
|
|
23
|
+
adjustSelectionAfterDelete,
|
|
24
|
+
diveIn,
|
|
25
|
+
ensureValidSelection,
|
|
26
|
+
getBreadcrumbPath,
|
|
27
|
+
getCurrentList,
|
|
28
|
+
getCurrentParent,
|
|
29
|
+
getSelectedNode,
|
|
30
|
+
goBack,
|
|
31
|
+
initializeNavigation,
|
|
32
|
+
moveDown,
|
|
33
|
+
moveUp,
|
|
34
|
+
} from "./navigation";
|
|
35
|
+
|
|
36
|
+
const colors = {
|
|
37
|
+
breadcrumbs: "#888888",
|
|
38
|
+
itemDone: "#FFFFFF",
|
|
39
|
+
itemOpen: "#888888",
|
|
40
|
+
itemSelected: "#FFFFFF",
|
|
41
|
+
keyHints: "#666666",
|
|
42
|
+
border: "#666666",
|
|
43
|
+
feedback: "#FFAA00",
|
|
44
|
+
modalBg: "#1a1a1a",
|
|
45
|
+
error: "#FF4444",
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
export class TUIApp {
|
|
49
|
+
private renderer: CliRenderer;
|
|
50
|
+
private state: AppState;
|
|
51
|
+
private isRunning = false;
|
|
52
|
+
private mainContainer!: BoxRenderable;
|
|
53
|
+
private breadcrumbText!: TextRenderable;
|
|
54
|
+
private listText!: TextRenderable;
|
|
55
|
+
private feedbackText!: TextRenderable;
|
|
56
|
+
private hintsText!: TextRenderable;
|
|
57
|
+
private modalContainer!: BoxRenderable | null;
|
|
58
|
+
|
|
59
|
+
constructor(renderer: CliRenderer, project: Project) {
|
|
60
|
+
this.renderer = renderer;
|
|
61
|
+
this.state = {
|
|
62
|
+
project,
|
|
63
|
+
navigationStack: [],
|
|
64
|
+
selectedIndex: 0,
|
|
65
|
+
modalState: null,
|
|
66
|
+
feedbackMessage: null,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
initializeNavigation(this.state);
|
|
70
|
+
this.setupUI();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private setupUI(): void {
|
|
74
|
+
const height = process.stdout.rows || 24;
|
|
75
|
+
|
|
76
|
+
this.mainContainer = new BoxRenderable(this.renderer, {
|
|
77
|
+
id: "main",
|
|
78
|
+
width: "100%",
|
|
79
|
+
height: "100%",
|
|
80
|
+
flexDirection: "column",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
this.breadcrumbText = new TextRenderable(this.renderer, {
|
|
84
|
+
id: "breadcrumb",
|
|
85
|
+
content: "",
|
|
86
|
+
fg: colors.breadcrumbs,
|
|
87
|
+
position: "absolute",
|
|
88
|
+
left: 1,
|
|
89
|
+
top: 0,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
this.listText = new TextRenderable(this.renderer, {
|
|
93
|
+
id: "list",
|
|
94
|
+
content: "",
|
|
95
|
+
fg: colors.itemOpen,
|
|
96
|
+
position: "absolute",
|
|
97
|
+
left: 1,
|
|
98
|
+
top: 2,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
this.feedbackText = new TextRenderable(this.renderer, {
|
|
102
|
+
id: "feedback",
|
|
103
|
+
content: "",
|
|
104
|
+
fg: colors.feedback,
|
|
105
|
+
attributes: TextAttributes.BOLD,
|
|
106
|
+
position: "absolute",
|
|
107
|
+
left: 2,
|
|
108
|
+
top: height - 3,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
this.hintsText = new TextRenderable(this.renderer, {
|
|
112
|
+
id: "hints",
|
|
113
|
+
content: "↑↓ select →/space enter ← back n new e edit d done x del q quit",
|
|
114
|
+
fg: colors.keyHints,
|
|
115
|
+
position: "absolute",
|
|
116
|
+
left: 1,
|
|
117
|
+
top: height - 1,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
this.renderer.root.add(this.mainContainer);
|
|
121
|
+
this.renderer.root.add(this.breadcrumbText);
|
|
122
|
+
this.renderer.root.add(this.listText);
|
|
123
|
+
this.renderer.root.add(this.feedbackText);
|
|
124
|
+
this.renderer.root.add(this.hintsText);
|
|
125
|
+
|
|
126
|
+
this.modalContainer = null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async start(): Promise<void> {
|
|
130
|
+
this.isRunning = true;
|
|
131
|
+
this.renderer.keyInput.on("keypress", this.handleKeyPress.bind(this));
|
|
132
|
+
this.updateDisplay();
|
|
133
|
+
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
const checkRunning = setInterval(() => {
|
|
136
|
+
if (!this.isRunning) {
|
|
137
|
+
clearInterval(checkRunning);
|
|
138
|
+
resolve();
|
|
139
|
+
}
|
|
140
|
+
}, 100);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private updateDisplay(): void {
|
|
145
|
+
const width = process.stdout.columns || 80;
|
|
146
|
+
ensureValidSelection(this.state);
|
|
147
|
+
this.breadcrumbText.content = this.formatBreadcrumb(width);
|
|
148
|
+
this.listText.content = this.formatList(width);
|
|
149
|
+
this.feedbackText.content = this.state.feedbackMessage || "";
|
|
150
|
+
this.updateModal();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private formatBreadcrumb(width: number): string {
|
|
154
|
+
const path = getBreadcrumbPath(this.state);
|
|
155
|
+
|
|
156
|
+
if (path.length === 0) {
|
|
157
|
+
return truncate(this.state.project.project_name, width - 2);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const segments = path.map((n) => truncate(n.title, 20));
|
|
161
|
+
let breadcrumb = segments.join(" > ");
|
|
162
|
+
|
|
163
|
+
if (breadcrumb.length > width - 2) {
|
|
164
|
+
while (segments.length > 1 && breadcrumb.length > width - 8) {
|
|
165
|
+
segments.shift();
|
|
166
|
+
breadcrumb = `... > ${segments.join(" > ")}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return breadcrumb;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private allDescendantsDone(node: Node): boolean {
|
|
174
|
+
for (const child of node.children) {
|
|
175
|
+
if (child.status !== "done") return false;
|
|
176
|
+
if (!this.allDescendantsDone(child)) return false;
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private getStatusDisplay(item: Node): string {
|
|
182
|
+
if (item.status !== "done") {
|
|
183
|
+
return "";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (item.children.length === 0) {
|
|
187
|
+
return " (done)";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (this.allDescendantsDone(item)) {
|
|
191
|
+
return " (done)";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return " (done, partial)";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private getChildCountDisplay(item: Node): string {
|
|
198
|
+
const count = countDescendants(item);
|
|
199
|
+
if (count === 0) return "";
|
|
200
|
+
return ` [${count}]`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private formatList(width: number): string {
|
|
204
|
+
const list = getCurrentList(this.state);
|
|
205
|
+
|
|
206
|
+
if (list.length === 0) {
|
|
207
|
+
return "No items. Press 'n' to create one.";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const lines: string[] = [];
|
|
211
|
+
const maxShow = Math.min(list.length, (process.stdout.rows || 24) - 6);
|
|
212
|
+
let startIdx = 0;
|
|
213
|
+
if (this.state.selectedIndex >= maxShow) {
|
|
214
|
+
startIdx = this.state.selectedIndex - maxShow + 1;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (let i = startIdx; i < Math.min(list.length, startIdx + maxShow); i++) {
|
|
218
|
+
const item = list[i];
|
|
219
|
+
const isSelected = i === this.state.selectedIndex;
|
|
220
|
+
const prefix = isSelected ? ">" : " ";
|
|
221
|
+
const status = this.getStatusDisplay(item);
|
|
222
|
+
const childCount = this.getChildCountDisplay(item);
|
|
223
|
+
const title = truncate(item.title, width - 25);
|
|
224
|
+
|
|
225
|
+
lines.push(`${prefix} ${title}${childCount}${status}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (list.length > maxShow) {
|
|
229
|
+
const remaining = list.length - startIdx - maxShow;
|
|
230
|
+
if (remaining > 0) {
|
|
231
|
+
lines.push(` +${remaining} more`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return lines.join("\n");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private updateModal(): void {
|
|
239
|
+
if (this.modalContainer) {
|
|
240
|
+
this.renderer.root.remove("modal");
|
|
241
|
+
this.modalContainer = null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!this.state.modalState) return;
|
|
245
|
+
|
|
246
|
+
const width = Math.min(50, (process.stdout.columns || 80) - 4);
|
|
247
|
+
const modal = this.state.modalState;
|
|
248
|
+
|
|
249
|
+
let title = "";
|
|
250
|
+
let content = "";
|
|
251
|
+
let buttons = "";
|
|
252
|
+
let hints = "";
|
|
253
|
+
|
|
254
|
+
switch (modal.type) {
|
|
255
|
+
case "new":
|
|
256
|
+
title = "New Task";
|
|
257
|
+
content = `Title: ${modal.inputValue || "(type here)"}\n${modal.errorMessage || ""}`;
|
|
258
|
+
buttons = modal.selectedButton === 0 ? "[Create] Cancel" : " Create [Cancel]";
|
|
259
|
+
hints = "Tab:switch Enter:confirm Esc:cancel";
|
|
260
|
+
break;
|
|
261
|
+
case "edit":
|
|
262
|
+
title = "Edit Task";
|
|
263
|
+
content = `Title: ${modal.inputValue || "(type here)"}\n${modal.errorMessage || ""}`;
|
|
264
|
+
buttons = modal.selectedButton === 0 ? "[Save] Cancel" : " Save [Cancel]";
|
|
265
|
+
hints = "Tab:switch Enter:confirm Esc:cancel";
|
|
266
|
+
break;
|
|
267
|
+
case "delete": {
|
|
268
|
+
const selected = getSelectedNode(this.state);
|
|
269
|
+
if (!selected) break;
|
|
270
|
+
const childCount = countDescendants(selected);
|
|
271
|
+
title = "Delete Task?";
|
|
272
|
+
content = `"${truncate(selected.title, width - 4)}"\n`;
|
|
273
|
+
content +=
|
|
274
|
+
childCount > 0
|
|
275
|
+
? `This will delete ${childCount} sub-tasks.`
|
|
276
|
+
: "This will delete this task.";
|
|
277
|
+
buttons = modal.selectedButton === 0 ? "[Delete] Cancel" : " Delete [Cancel]";
|
|
278
|
+
hints = "Tab:switch buttons Enter:confirm Esc:cancel";
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
case "help":
|
|
282
|
+
title = "Key Bindings";
|
|
283
|
+
content = [
|
|
284
|
+
"↑/k Move up",
|
|
285
|
+
"↓/j Move down",
|
|
286
|
+
"→/l Enter / dive in",
|
|
287
|
+
"←/h Back / go up",
|
|
288
|
+
"Enter Enter selected",
|
|
289
|
+
"n New task",
|
|
290
|
+
"e Edit selected",
|
|
291
|
+
"d Toggle done",
|
|
292
|
+
"x Delete selected",
|
|
293
|
+
"q Quit",
|
|
294
|
+
].join("\n");
|
|
295
|
+
buttons = "";
|
|
296
|
+
hints = "Press any key to close";
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const modalHeight = modal.type === "help" ? 15 : 10;
|
|
301
|
+
const left = Math.floor(((process.stdout.columns || 80) - width) / 2);
|
|
302
|
+
const top = Math.floor(((process.stdout.rows || 24) - modalHeight) / 2);
|
|
303
|
+
|
|
304
|
+
this.modalContainer = new BoxRenderable(this.renderer, {
|
|
305
|
+
id: "modal",
|
|
306
|
+
width,
|
|
307
|
+
height: modalHeight,
|
|
308
|
+
position: "absolute",
|
|
309
|
+
left,
|
|
310
|
+
top,
|
|
311
|
+
backgroundColor: colors.modalBg,
|
|
312
|
+
borderStyle: "single",
|
|
313
|
+
borderColor: colors.border,
|
|
314
|
+
title,
|
|
315
|
+
titleAlignment: "left",
|
|
316
|
+
padding: 1,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const modalContent = new TextRenderable(this.renderer, {
|
|
320
|
+
id: "modal-content",
|
|
321
|
+
content: `${content}\n\n${buttons}\n\n${hints}`,
|
|
322
|
+
fg: "#FFFFFF",
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
this.modalContainer.add(modalContent);
|
|
326
|
+
this.renderer.root.add(this.modalContainer);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private handleKeyPress(key: KeyEvent): void {
|
|
330
|
+
if (this.state.modalState) {
|
|
331
|
+
this.handleModalKeyPress(key);
|
|
332
|
+
} else {
|
|
333
|
+
this.handleNavigationKeyPress(key);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private handleNavigationKeyPress(key: KeyEvent): void {
|
|
338
|
+
const keyName = key.name?.toLowerCase() || key.sequence;
|
|
339
|
+
|
|
340
|
+
switch (keyName) {
|
|
341
|
+
case "up":
|
|
342
|
+
case "k":
|
|
343
|
+
this.handleNavigation(moveUp(this.state));
|
|
344
|
+
break;
|
|
345
|
+
|
|
346
|
+
case "down":
|
|
347
|
+
case "j":
|
|
348
|
+
this.handleNavigation(moveDown(this.state));
|
|
349
|
+
break;
|
|
350
|
+
|
|
351
|
+
case "right":
|
|
352
|
+
case "l":
|
|
353
|
+
case "return":
|
|
354
|
+
case "enter":
|
|
355
|
+
case "space":
|
|
356
|
+
this.handleNavigation(diveIn(this.state));
|
|
357
|
+
break;
|
|
358
|
+
|
|
359
|
+
case "left":
|
|
360
|
+
case "h":
|
|
361
|
+
this.handleNavigation(goBack(this.state));
|
|
362
|
+
break;
|
|
363
|
+
|
|
364
|
+
case "n":
|
|
365
|
+
this.openModal("new");
|
|
366
|
+
break;
|
|
367
|
+
|
|
368
|
+
case "e":
|
|
369
|
+
this.openEditModal();
|
|
370
|
+
break;
|
|
371
|
+
|
|
372
|
+
case "d":
|
|
373
|
+
this.toggleDone();
|
|
374
|
+
break;
|
|
375
|
+
|
|
376
|
+
case "x":
|
|
377
|
+
this.openDeleteModal();
|
|
378
|
+
break;
|
|
379
|
+
|
|
380
|
+
case "?":
|
|
381
|
+
this.openModal("help");
|
|
382
|
+
break;
|
|
383
|
+
|
|
384
|
+
case "r":
|
|
385
|
+
this.updateDisplay();
|
|
386
|
+
break;
|
|
387
|
+
|
|
388
|
+
case "q":
|
|
389
|
+
this.quit();
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private handleNavigation(result: { success: boolean; feedbackMessage?: string }): void {
|
|
395
|
+
if (!result.success && result.feedbackMessage) {
|
|
396
|
+
this.showFeedback(result.feedbackMessage);
|
|
397
|
+
}
|
|
398
|
+
this.updateDisplay();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private openModal(type: ModalState["type"]): void {
|
|
402
|
+
this.state.modalState = {
|
|
403
|
+
type,
|
|
404
|
+
inputValue: "",
|
|
405
|
+
selectedButton: 0,
|
|
406
|
+
};
|
|
407
|
+
this.updateDisplay();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private openEditModal(): void {
|
|
411
|
+
const selected = getSelectedNode(this.state);
|
|
412
|
+
if (!selected) {
|
|
413
|
+
this.showFeedback("Nothing selected");
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
this.state.modalState = {
|
|
418
|
+
type: "edit",
|
|
419
|
+
inputValue: selected.title,
|
|
420
|
+
selectedButton: 0,
|
|
421
|
+
};
|
|
422
|
+
this.updateDisplay();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private openDeleteModal(): void {
|
|
426
|
+
const selected = getSelectedNode(this.state);
|
|
427
|
+
if (!selected) {
|
|
428
|
+
this.showFeedback("Nothing selected");
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
this.state.modalState = {
|
|
433
|
+
type: "delete",
|
|
434
|
+
selectedButton: 1,
|
|
435
|
+
};
|
|
436
|
+
this.updateDisplay();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private closeModal(): void {
|
|
440
|
+
this.state.modalState = null;
|
|
441
|
+
this.updateDisplay();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private handleModalKeyPress(key: KeyEvent): void {
|
|
445
|
+
if (!this.state.modalState) return;
|
|
446
|
+
|
|
447
|
+
const modal = this.state.modalState;
|
|
448
|
+
const keyName = key.name?.toLowerCase() || key.sequence;
|
|
449
|
+
|
|
450
|
+
if (modal.type === "help") {
|
|
451
|
+
this.closeModal();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
switch (keyName) {
|
|
456
|
+
case "escape":
|
|
457
|
+
this.closeModal();
|
|
458
|
+
break;
|
|
459
|
+
|
|
460
|
+
case "tab":
|
|
461
|
+
modal.selectedButton = modal.selectedButton === 0 ? 1 : 0;
|
|
462
|
+
this.updateDisplay();
|
|
463
|
+
break;
|
|
464
|
+
|
|
465
|
+
case "return":
|
|
466
|
+
case "enter":
|
|
467
|
+
this.handleModalSubmit();
|
|
468
|
+
break;
|
|
469
|
+
|
|
470
|
+
case "backspace":
|
|
471
|
+
case "delete":
|
|
472
|
+
if (modal.type === "new" || modal.type === "edit") {
|
|
473
|
+
modal.inputValue = (modal.inputValue || "").slice(0, -1);
|
|
474
|
+
modal.errorMessage = undefined;
|
|
475
|
+
this.updateDisplay();
|
|
476
|
+
}
|
|
477
|
+
break;
|
|
478
|
+
|
|
479
|
+
default:
|
|
480
|
+
if ((modal.type === "new" || modal.type === "edit") && key.sequence) {
|
|
481
|
+
if (key.sequence.length === 1 && key.sequence.charCodeAt(0) >= 32) {
|
|
482
|
+
const char = key.sequence;
|
|
483
|
+
let sanitized: string | null = null;
|
|
484
|
+
if (/^[a-zA-Z]$/.test(char)) {
|
|
485
|
+
sanitized = char.toLowerCase();
|
|
486
|
+
} else if (/^[0-9 _-]$/.test(char)) {
|
|
487
|
+
sanitized = char;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (sanitized) {
|
|
491
|
+
modal.inputValue = (modal.inputValue || "") + sanitized;
|
|
492
|
+
modal.errorMessage = undefined;
|
|
493
|
+
} else {
|
|
494
|
+
modal.errorMessage = "Only letters, numbers, spaces, - and _ allowed";
|
|
495
|
+
}
|
|
496
|
+
this.updateDisplay();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private handleModalSubmit(): void {
|
|
504
|
+
if (!this.state.modalState) return;
|
|
505
|
+
|
|
506
|
+
const modal = this.state.modalState;
|
|
507
|
+
|
|
508
|
+
if (modal.selectedButton === 1) {
|
|
509
|
+
this.closeModal();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
switch (modal.type) {
|
|
514
|
+
case "new":
|
|
515
|
+
this.submitNewNode();
|
|
516
|
+
break;
|
|
517
|
+
case "edit":
|
|
518
|
+
this.submitEditNode();
|
|
519
|
+
break;
|
|
520
|
+
case "delete":
|
|
521
|
+
this.submitDelete();
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private getParentForNewItem(): Node {
|
|
527
|
+
const parent = getCurrentParent(this.state);
|
|
528
|
+
return parent || this.state.project.root;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private async submitNewNode(): Promise<void> {
|
|
532
|
+
if (!this.state.modalState) return;
|
|
533
|
+
|
|
534
|
+
const title = this.state.modalState.inputValue || "";
|
|
535
|
+
const validation = validateTitle(title);
|
|
536
|
+
|
|
537
|
+
if (!validation.isValid) {
|
|
538
|
+
this.state.modalState.errorMessage = validation.error;
|
|
539
|
+
this.updateDisplay();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const parent = this.getParentForNewItem();
|
|
544
|
+
addChildNode(parent, title);
|
|
545
|
+
|
|
546
|
+
await this.save();
|
|
547
|
+
ensureValidSelection(this.state);
|
|
548
|
+
this.closeModal();
|
|
549
|
+
this.showFeedback("Created task");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private async submitEditNode(): Promise<void> {
|
|
553
|
+
if (!this.state.modalState) return;
|
|
554
|
+
|
|
555
|
+
const title = this.state.modalState.inputValue || "";
|
|
556
|
+
const validation = validateTitle(title);
|
|
557
|
+
|
|
558
|
+
if (!validation.isValid) {
|
|
559
|
+
this.state.modalState.errorMessage = validation.error;
|
|
560
|
+
this.updateDisplay();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const selected = getSelectedNode(this.state);
|
|
565
|
+
if (!selected) {
|
|
566
|
+
this.closeModal();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
editNodeTitle(selected, title);
|
|
571
|
+
|
|
572
|
+
await this.save();
|
|
573
|
+
this.closeModal();
|
|
574
|
+
this.showFeedback("Updated");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private async submitDelete(): Promise<void> {
|
|
578
|
+
const selected = getSelectedNode(this.state);
|
|
579
|
+
if (!selected) {
|
|
580
|
+
this.closeModal();
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const parent = this.getParentForNewItem();
|
|
585
|
+
const deletedIndex = this.state.selectedIndex;
|
|
586
|
+
|
|
587
|
+
deleteNode(parent, selected.id);
|
|
588
|
+
adjustSelectionAfterDelete(this.state, deletedIndex);
|
|
589
|
+
|
|
590
|
+
await this.save();
|
|
591
|
+
this.closeModal();
|
|
592
|
+
this.showFeedback("Deleted");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private async toggleDone(): Promise<void> {
|
|
596
|
+
const selected = getSelectedNode(this.state);
|
|
597
|
+
if (!selected) {
|
|
598
|
+
this.showFeedback("Nothing selected");
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const wasDone = selected.status === "done";
|
|
603
|
+
toggleNodeStatus(selected);
|
|
604
|
+
await this.save();
|
|
605
|
+
|
|
606
|
+
const message = wasDone ? "Marked open" : "Done";
|
|
607
|
+
this.showFeedback(message);
|
|
608
|
+
this.updateDisplay();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private showFeedback(message: string): void {
|
|
612
|
+
this.state.feedbackMessage = message;
|
|
613
|
+
this.updateDisplay();
|
|
614
|
+
|
|
615
|
+
setTimeout(() => {
|
|
616
|
+
if (this.state.feedbackMessage === message) {
|
|
617
|
+
this.state.feedbackMessage = null;
|
|
618
|
+
this.updateDisplay();
|
|
619
|
+
}
|
|
620
|
+
}, 1500);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private async save(): Promise<void> {
|
|
624
|
+
try {
|
|
625
|
+
await saveProject(this.state.project);
|
|
626
|
+
} catch {
|
|
627
|
+
this.showFeedback("Failed to save");
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
private async quit(): Promise<void> {
|
|
632
|
+
await this.save();
|
|
633
|
+
|
|
634
|
+
try {
|
|
635
|
+
this.renderer.stop();
|
|
636
|
+
} catch {
|
|
637
|
+
// Ignore errors during stop
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
restoreTerminal();
|
|
641
|
+
this.isRunning = false;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function restoreTerminal(): void {
|
|
646
|
+
try {
|
|
647
|
+
if (process.stdin.isTTY && process.stdin.isRaw) {
|
|
648
|
+
process.stdin.setRawMode(false);
|
|
649
|
+
}
|
|
650
|
+
} catch {
|
|
651
|
+
// Ignore errors
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const sequences = [
|
|
655
|
+
"\x1B[?1049l",
|
|
656
|
+
"\x1B[?25h",
|
|
657
|
+
"\x1B[0m",
|
|
658
|
+
"\x1B[?1000l",
|
|
659
|
+
"\x1B[?1002l",
|
|
660
|
+
"\x1B[?1003l",
|
|
661
|
+
"\x1B[?1006l",
|
|
662
|
+
"\x1B[2J",
|
|
663
|
+
"\x1B[H",
|
|
664
|
+
"\x1B[?25h",
|
|
665
|
+
].join("");
|
|
666
|
+
|
|
667
|
+
try {
|
|
668
|
+
writeSync(1, sequences);
|
|
669
|
+
} catch {
|
|
670
|
+
process.stdout.write(sequences);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function disableMouseTracking(): void {
|
|
675
|
+
const sequences = [
|
|
676
|
+
"\x1B[?1000l",
|
|
677
|
+
"\x1B[?1002l",
|
|
678
|
+
"\x1B[?1003l",
|
|
679
|
+
"\x1B[?1006l",
|
|
680
|
+
"\x1B[?1015l",
|
|
681
|
+
].join("");
|
|
682
|
+
process.stdout.write(sequences);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export async function startTUI(project: Project): Promise<void> {
|
|
686
|
+
disableMouseTracking();
|
|
687
|
+
|
|
688
|
+
const renderer = await createCliRenderer({
|
|
689
|
+
exitOnCtrlC: true,
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
disableMouseTracking();
|
|
693
|
+
|
|
694
|
+
const cleanup = () => {
|
|
695
|
+
try {
|
|
696
|
+
renderer.stop();
|
|
697
|
+
} catch {
|
|
698
|
+
// Ignore
|
|
699
|
+
}
|
|
700
|
+
restoreTerminal();
|
|
701
|
+
process.exit(0);
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
process.on("SIGINT", cleanup);
|
|
705
|
+
process.on("SIGTERM", cleanup);
|
|
706
|
+
process.on("exit", () => {
|
|
707
|
+
restoreTerminal();
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
const app = new TUIApp(renderer, project);
|
|
711
|
+
|
|
712
|
+
try {
|
|
713
|
+
await app.start();
|
|
714
|
+
} finally {
|
|
715
|
+
restoreTerminal();
|
|
716
|
+
}
|
|
717
|
+
}
|