depth-first-thinking 2.1.7 → 2.1.9

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 CHANGED
@@ -29,21 +29,25 @@ bun add -g depth-first-thinking
29
29
  ### Default Behavior
30
30
 
31
31
  Running `dft` without any arguments will:
32
- - Open the most frequently opened project if one exists
32
+ - Reopen the **last opened project** if it still exists
33
+ - Otherwise, open the most frequently opened project if one exists
33
34
  - Otherwise, list all projects
34
35
 
35
36
  ## Navigation
36
37
 
37
- When you open a project, **Zen Mode** is enabled by default. In Zen Mode, only the currently selected task is displayed, while navigation still works across the full tree:
38
+ When you open a project, **Zen Mode** or **List Mode** is restored from your last session (default is Zen). In Zen Mode, only the currently selected task is displayed, while navigation still works across the full tree:
38
39
 
39
- - `↑` `↓` - Move between sibling tasks at the current level
40
+ - `↑` `↓` - Move between sibling tasks at the current level (nodes with more children appear first)
40
41
  - `→` `Space` `Enter` - Enter task / view its subtasks (move deeper in the hierarchy)
41
42
  - `←` - Go back to parent task (move up in the hierarchy)
42
- - `m` - Toggle between **Zen Mode** (single-task view) and **List Mode** (full list at current level)
43
+ - `Cmd+C` - Copy the selected task title to the clipboard
44
+ - `m` - Toggle between **Zen Mode** (single-task view) and **List Mode** (full list at current level); choice is remembered across projects
43
45
  - `n` - New subtask
44
46
  - `e` - Edit task title
45
47
  - `d` - Toggle done status
46
- - `x` - Delete task
48
+ - `x` - Delete task (confirmation defaults to Delete)
49
+ - `v` - Move the current task into another task
50
+ - `/` or `f` - Search tasks by title and jump to a result
47
51
  - `q` - Quit
48
52
 
49
53
  ## Task Display
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "depth-first-thinking",
3
- "version": "2.1.7",
3
+ "version": "2.1.9",
4
4
  "description": "A terminal-based task manager with depth-first navigation.",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,3 +1,4 @@
1
+ import { saveConfig } from "../data/config";
1
2
  import { loadProject, saveProject } from "../data/storage";
2
3
  import type { Project } from "../data/types";
3
4
  import { ExitCodes } from "../data/types";
@@ -28,6 +29,12 @@ export async function openCommand(projectName: string): Promise<void> {
28
29
  console.error(error instanceof Error ? error.message : "Failed to update open count");
29
30
  }
30
31
 
32
+ try {
33
+ await saveConfig({ lastOpenedProject: normalizedName });
34
+ } catch {
35
+ // Ignore config write failure
36
+ }
37
+
31
38
  try {
32
39
  await startTUI(project);
33
40
  process.exit(ExitCodes.SUCCESS);
@@ -0,0 +1,47 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { getConfigPath } from "../utils/platform";
4
+ import type { ViewMode } from "./types";
5
+
6
+ export interface AppConfig {
7
+ viewMode?: ViewMode;
8
+ lastOpenedProject?: string;
9
+ }
10
+
11
+ const defaults: AppConfig = {
12
+ viewMode: "zen",
13
+ };
14
+
15
+ export async function loadConfig(): Promise<AppConfig> {
16
+ const path = getConfigPath();
17
+ const file = Bun.file(path);
18
+ if (!(await file.exists())) {
19
+ return { ...defaults };
20
+ }
21
+ let data: unknown;
22
+ try {
23
+ data = await file.json();
24
+ } catch {
25
+ return { ...defaults };
26
+ }
27
+ if (!data || typeof data !== "object") {
28
+ return { ...defaults };
29
+ }
30
+ const obj = data as Record<string, unknown>;
31
+ return {
32
+ viewMode: obj.viewMode === "zen" || obj.viewMode === "list" ? obj.viewMode : defaults.viewMode,
33
+ lastOpenedProject:
34
+ typeof obj.lastOpenedProject === "string" ? obj.lastOpenedProject : undefined,
35
+ };
36
+ }
37
+
38
+ export async function saveConfig(partial: Partial<AppConfig>): Promise<void> {
39
+ const path = getConfigPath();
40
+ const current = await loadConfig();
41
+ const merged: AppConfig = {
42
+ ...current,
43
+ ...partial,
44
+ };
45
+ await mkdir(dirname(path), { recursive: true });
46
+ await Bun.write(path, JSON.stringify(merged, null, 2));
47
+ }
@@ -12,6 +12,7 @@ import {
12
12
  getSiblingIndex,
13
13
  markNodeDone,
14
14
  markNodeOpen,
15
+ moveNode,
15
16
  toggleNodeStatus,
16
17
  } from "./operations";
17
18
  import type { Node } from "./types";
@@ -282,3 +283,39 @@ describe("getNodePath", () => {
282
283
  expect(path?.[1]).toBe(child);
283
284
  });
284
285
  });
286
+
287
+ describe("moveNode", () => {
288
+ test("moves node to new parent", () => {
289
+ const root = createTestNode("Root", "root");
290
+ const a = addChildNode(root, "A");
291
+ const b = addChildNode(root, "B");
292
+ const c = addChildNode(a, "C");
293
+
294
+ const result = moveNode(root, c.id, b.id);
295
+ expect(result).toBe(true);
296
+ expect(root.children).toHaveLength(2);
297
+ expect(a.children).toHaveLength(0);
298
+ expect(b.children).toHaveLength(1);
299
+ expect(b.children[0]).toBe(c);
300
+ });
301
+
302
+ test("rejects moving node into itself", () => {
303
+ const root = createTestNode("Root", "root");
304
+ const a = addChildNode(root, "A");
305
+
306
+ const result = moveNode(root, a.id, a.id);
307
+ expect(result).toBe(false);
308
+ expect(root.children).toHaveLength(1);
309
+ });
310
+
311
+ test("rejects moving node into its descendant", () => {
312
+ const root = createTestNode("Root", "root");
313
+ const a = addChildNode(root, "A");
314
+ const b = addChildNode(a, "B");
315
+
316
+ const result = moveNode(root, a.id, b.id);
317
+ expect(result).toBe(false);
318
+ expect(root.children).toHaveLength(1);
319
+ expect(a.children).toHaveLength(1);
320
+ });
321
+ });
@@ -109,3 +109,57 @@ export function countDescendants(node: Node): number {
109
109
  }
110
110
  return count;
111
111
  }
112
+
113
+ export function moveNode(root: Node, nodeId: string, targetParentId: string): boolean {
114
+ const node = findNode(root, nodeId);
115
+ if (!node) return false;
116
+
117
+ const currentParent = findParent(root, nodeId);
118
+ if (!currentParent) return false;
119
+
120
+ const targetParent = findNode(root, targetParentId);
121
+ if (!targetParent) return false;
122
+
123
+ if (targetParentId === nodeId) return false;
124
+
125
+ const pathToTarget = buildPathToNode(root, targetParentId);
126
+ if (pathToTarget?.includes(nodeId)) return false;
127
+
128
+ const removed = deleteNode(currentParent, nodeId);
129
+ if (!removed) return false;
130
+
131
+ targetParent.children.push(node);
132
+ return true;
133
+ }
134
+
135
+ export interface NodeWithPath {
136
+ node: Node;
137
+ path: Node[];
138
+ }
139
+
140
+ function collectNodesWithPaths(node: Node, pathSoFar: Node[], acc: NodeWithPath[]): void {
141
+ const path = pathSoFar.concat(node);
142
+ acc.push({ node, path });
143
+ for (const child of node.children) {
144
+ collectNodesWithPaths(child, path, acc);
145
+ }
146
+ }
147
+
148
+ export function getValidMoveTargets(root: Node, excludeNodeId: string): NodeWithPath[] {
149
+ const all: NodeWithPath[] = [];
150
+ collectNodesWithPaths(root, [], all);
151
+ return all.filter(({ node }) => {
152
+ if (node.id === excludeNodeId) return false;
153
+ const path = buildPathToNode(root, node.id);
154
+ return path && !path.includes(excludeNodeId);
155
+ });
156
+ }
157
+
158
+ export function searchNodes(root: Node, query: string): NodeWithPath[] {
159
+ const q = query.trim().toLowerCase();
160
+ if (q.length === 0) return [];
161
+
162
+ const all: NodeWithPath[] = [];
163
+ collectNodesWithPaths(root, [], all);
164
+ return all.filter(({ node }) => node.title.toLowerCase().includes(q));
165
+ }
package/src/data/types.ts CHANGED
@@ -18,13 +18,30 @@ export interface Project {
18
18
  open_count?: number;
19
19
  }
20
20
 
21
- export type ModalType = "new" | "edit" | "delete" | "help";
21
+ export type ModalType = "new" | "edit" | "delete" | "help" | "move" | "search";
22
+
23
+ export interface MoveTarget {
24
+ nodeId: string;
25
+ title: string;
26
+ pathTitles: string[];
27
+ }
28
+
29
+ export interface SearchResult {
30
+ nodeId: string;
31
+ title: string;
32
+ pathTitles: string[];
33
+ }
22
34
 
23
35
  export interface ModalState {
24
36
  type: ModalType;
25
37
  inputValue?: string;
26
38
  errorMessage?: string;
27
39
  selectedButton?: number;
40
+ moveTargets?: MoveTarget[];
41
+ selectedTargetIndex?: number;
42
+ nodeIdToMove?: string;
43
+ searchResults?: SearchResult[];
44
+ selectedResultIndex?: number;
28
45
  }
29
46
 
30
47
  export type ViewMode = "list" | "zen";
@@ -33,6 +50,7 @@ export interface AppState {
33
50
  project: Project;
34
51
  navigationStack: string[];
35
52
  selectedIndex: number;
53
+ selectedNodeId: string | null;
36
54
  viewMode: ViewMode;
37
55
  modalState: ModalState | null;
38
56
  feedbackMessage: string | null;
package/src/index.ts CHANGED
@@ -7,7 +7,8 @@ import { newCommand } from "./commands/new";
7
7
  import { openCommand } from "./commands/open";
8
8
  import { treeCommand } from "./commands/tree";
9
9
  import { updateCommand } from "./commands/update";
10
- import { getMostOpenedProject } from "./data/storage";
10
+ import { loadConfig } from "./data/config";
11
+ import { getMostOpenedProject, projectExists } from "./data/storage";
11
12
  import { isValidProjectName } from "./utils/validation";
12
13
  import { VERSION } from "./version";
13
14
 
@@ -97,6 +98,11 @@ async function main() {
97
98
  const args = process.argv.slice(2);
98
99
 
99
100
  if (args.length === 0) {
101
+ const config = await loadConfig();
102
+ if (config.lastOpenedProject && (await projectExists(config.lastOpenedProject))) {
103
+ await openCommand(config.lastOpenedProject);
104
+ return;
105
+ }
100
106
  const mostOpenedProject = await getMostOpenedProject();
101
107
  if (mostOpenedProject) {
102
108
  await openCommand(mostOpenedProject);
@@ -50,6 +50,7 @@ describe("viewMode and navigation state", () => {
50
50
  project,
51
51
  navigationStack: [],
52
52
  selectedIndex: 0,
53
+ selectedNodeId: null,
53
54
  viewMode: "zen",
54
55
  modalState: null,
55
56
  feedbackMessage: null,
@@ -69,6 +70,7 @@ describe("viewMode and navigation state", () => {
69
70
  project,
70
71
  navigationStack: [],
71
72
  selectedIndex: 1,
73
+ selectedNodeId: "b",
72
74
  viewMode: "zen",
73
75
  modalState: null,
74
76
  feedbackMessage: null,
@@ -88,6 +90,7 @@ describe("viewMode and navigation state", () => {
88
90
  project,
89
91
  navigationStack: [],
90
92
  selectedIndex: 1,
93
+ selectedNodeId: "b",
91
94
  viewMode: "zen",
92
95
  modalState: null,
93
96
  feedbackMessage: null,
package/src/tui/app.ts CHANGED
@@ -7,16 +7,29 @@ import {
7
7
  TextRenderable,
8
8
  createCliRenderer,
9
9
  } from "@opentui/core";
10
+ import { loadConfig, saveConfig } from "../data/config";
10
11
  import {
11
12
  addChildNode,
12
13
  countDescendants,
13
14
  deleteNode,
14
15
  editNodeTitle,
15
16
  findNode,
17
+ getValidMoveTargets,
18
+ moveNode,
19
+ searchNodes,
16
20
  toggleNodeStatus,
17
21
  } from "../data/operations";
18
22
  import { saveProject } from "../data/storage";
19
- import type { AppState, ModalState, Node, Project, ViewMode } from "../data/types";
23
+ import type {
24
+ AppState,
25
+ ModalState,
26
+ MoveTarget,
27
+ Node,
28
+ Project,
29
+ SearchResult,
30
+ ViewMode,
31
+ } from "../data/types";
32
+ import { writeToClipboard } from "../utils/clipboard";
20
33
  import { truncate } from "../utils/formatting";
21
34
  import { validateTitle } from "../utils/validation";
22
35
  import {
@@ -31,6 +44,7 @@ import {
31
44
  initializeNavigation,
32
45
  moveDown,
33
46
  moveUp,
47
+ navigateToNode,
34
48
  } from "./navigation";
35
49
 
36
50
  const colors = {
@@ -56,13 +70,14 @@ export class TUIApp {
56
70
  private hintsText!: TextRenderable;
57
71
  private modalContainer!: BoxRenderable | null;
58
72
 
59
- constructor(renderer: CliRenderer, project: Project) {
73
+ constructor(renderer: CliRenderer, project: Project, initialViewMode?: ViewMode) {
60
74
  this.renderer = renderer;
61
75
  this.state = {
62
76
  project,
63
77
  navigationStack: [],
64
78
  selectedIndex: 0,
65
- viewMode: "zen",
79
+ selectedNodeId: null,
80
+ viewMode: initialViewMode ?? "zen",
66
81
  modalState: null,
67
82
  feedbackMessage: null,
68
83
  };
@@ -111,7 +126,8 @@ export class TUIApp {
111
126
 
112
127
  this.hintsText = new TextRenderable(this.renderer, {
113
128
  id: "hints",
114
- content: "↑↓ select →/space enter ← back n new e edit d done x del m mode q quit",
129
+ content:
130
+ "↑↓ select →/space enter ← back n new e edit d done x del m mode v move / search q quit",
115
131
  fg: colors.keyHints,
116
132
  position: "absolute",
117
133
  left: 1,
@@ -318,14 +334,53 @@ export class TUIApp {
318
334
  "e Edit selected",
319
335
  "d Toggle done",
320
336
  "x Delete selected",
337
+ "v Move to...",
338
+ "/ or f Search",
321
339
  "q Quit",
322
340
  ].join("\n");
323
341
  buttons = "";
324
342
  hints = "Press any key to close";
325
343
  break;
344
+ case "move": {
345
+ const targets = modal.moveTargets ?? [];
346
+ const idx = modal.selectedTargetIndex ?? 0;
347
+ const lines = targets.map((t, i) => {
348
+ const prefix = i === idx ? ">" : " ";
349
+ const pathStr = t.pathTitles.length > 0 ? ` (${t.pathTitles.join(" > ")})` : "";
350
+ return `${prefix} ${truncate(t.title, width - 8)}${pathStr}`;
351
+ });
352
+ title = "Move to";
353
+ content = lines.length > 0 ? lines.join("\n") : "No valid targets.";
354
+ hints = "↑↓ select Enter:move Esc:cancel";
355
+ break;
356
+ }
357
+ case "search": {
358
+ const results = modal.searchResults ?? [];
359
+ const idx = modal.selectedResultIndex ?? 0;
360
+ const queryLine = `Query: ${modal.inputValue ?? ""}`;
361
+ const lines = results.map((r, i) => {
362
+ const prefix = i === idx ? ">" : " ";
363
+ const pathStr = r.pathTitles.length > 0 ? ` (${r.pathTitles.join(" > ")})` : "";
364
+ return `${prefix} ${truncate(r.title, width - 8)}${pathStr}`;
365
+ });
366
+ title = "Search";
367
+ content = [queryLine, "", ...lines].join("\n");
368
+ if (results.length === 0 && (modal.inputValue?.trim().length ?? 0) > 0) {
369
+ content += "\nNo matches.";
370
+ }
371
+ hints = "Type to search ↑↓ select Enter:go Esc:cancel";
372
+ break;
373
+ }
326
374
  }
327
375
 
328
- const modalHeight = modal.type === "help" ? 15 : 10;
376
+ const modalHeight =
377
+ modal.type === "help"
378
+ ? 15
379
+ : modal.type === "move"
380
+ ? Math.min(14, (modal.moveTargets?.length ?? 0) + 6)
381
+ : modal.type === "search"
382
+ ? Math.min(16, (modal.searchResults?.length ?? 0) + 8)
383
+ : 10;
329
384
  const left = Math.floor(((process.stdout.columns || 80) - width) / 2);
330
385
  const top = Math.floor(((process.stdout.rows || 24) - modalHeight) / 2);
331
386
 
@@ -363,6 +418,17 @@ export class TUIApp {
363
418
  }
364
419
 
365
420
  private handleNavigationKeyPress(key: KeyEvent): void {
421
+ if (key.meta && key.name?.toLowerCase() === "c") {
422
+ const selected = getSelectedNode(this.state);
423
+ const text = selected?.title ?? "";
424
+ if (text) {
425
+ writeToClipboard(text).then((ok) => {
426
+ this.showFeedback(ok ? "Copied" : "Copy failed");
427
+ });
428
+ }
429
+ return;
430
+ }
431
+
366
432
  const keyName = key.name?.toLowerCase() || key.sequence;
367
433
 
368
434
  switch (keyName) {
@@ -420,12 +486,24 @@ export class TUIApp {
420
486
  case "m":
421
487
  this.toggleViewMode();
422
488
  break;
489
+
490
+ case "v":
491
+ this.openMoveModal();
492
+ break;
493
+
494
+ case "/":
495
+ case "f":
496
+ this.openSearchModal();
497
+ break;
423
498
  }
424
499
  }
425
500
 
426
501
  private toggleViewMode(): void {
427
502
  const currentMode: ViewMode = this.state.viewMode;
428
503
  this.state.viewMode = currentMode === "zen" ? "list" : "zen";
504
+ saveConfig({ viewMode: this.state.viewMode }).catch(() => {
505
+ // Ignore config write failure
506
+ });
429
507
  this.showFeedback(this.state.viewMode === "zen" ? "Zen Mode" : "List Mode");
430
508
  }
431
509
 
@@ -469,11 +547,67 @@ export class TUIApp {
469
547
 
470
548
  this.state.modalState = {
471
549
  type: "delete",
472
- selectedButton: 1,
550
+ selectedButton: 0,
551
+ };
552
+ this.updateDisplay();
553
+ }
554
+
555
+ private openMoveModal(): void {
556
+ const selected = getSelectedNode(this.state);
557
+ if (!selected) {
558
+ this.showFeedback("Nothing selected");
559
+ return;
560
+ }
561
+
562
+ const raw = getValidMoveTargets(this.state.project.root, selected.id);
563
+ const moveTargets: MoveTarget[] = raw.map(({ node, path }) => ({
564
+ nodeId: node.id,
565
+ title: node.title,
566
+ pathTitles: path.slice(1).map((p) => p.title),
567
+ }));
568
+
569
+ if (moveTargets.length === 0) {
570
+ this.showFeedback("No valid targets");
571
+ return;
572
+ }
573
+
574
+ this.state.modalState = {
575
+ type: "move",
576
+ moveTargets,
577
+ selectedTargetIndex: 0,
578
+ nodeIdToMove: selected.id,
473
579
  };
474
580
  this.updateDisplay();
475
581
  }
476
582
 
583
+ private openSearchModal(): void {
584
+ this.state.modalState = {
585
+ type: "search",
586
+ inputValue: "",
587
+ searchResults: [],
588
+ selectedResultIndex: 0,
589
+ };
590
+ this.updateDisplay();
591
+ }
592
+
593
+ private runSearchAndUpdateResults(): void {
594
+ const modal = this.state.modalState;
595
+ if (!modal || modal.type !== "search") return;
596
+
597
+ const query = modal.inputValue ?? "";
598
+ const raw = searchNodes(this.state.project.root, query);
599
+ const searchResults: SearchResult[] = raw.map(({ node, path }) => ({
600
+ nodeId: node.id,
601
+ title: node.title,
602
+ pathTitles: path.slice(1).map((p) => p.title),
603
+ }));
604
+
605
+ modal.searchResults = searchResults;
606
+ const idx = modal.selectedResultIndex ?? 0;
607
+ modal.selectedResultIndex =
608
+ searchResults.length === 0 ? 0 : Math.min(idx, searchResults.length - 1);
609
+ }
610
+
477
611
  private closeModal(): void {
478
612
  this.state.modalState = null;
479
613
  this.updateDisplay();
@@ -490,6 +624,84 @@ export class TUIApp {
490
624
  return;
491
625
  }
492
626
 
627
+ if (modal.type === "move") {
628
+ const targets = modal.moveTargets ?? [];
629
+ const len = targets.length;
630
+ if (len === 0) {
631
+ if (keyName === "escape") this.closeModal();
632
+ return;
633
+ }
634
+ let idx = modal.selectedTargetIndex ?? 0;
635
+ switch (keyName) {
636
+ case "escape":
637
+ this.closeModal();
638
+ return;
639
+ case "up":
640
+ case "k":
641
+ idx = idx <= 0 ? 0 : idx - 1;
642
+ modal.selectedTargetIndex = idx;
643
+ this.updateDisplay();
644
+ return;
645
+ case "down":
646
+ case "j":
647
+ idx = idx >= len - 1 ? len - 1 : idx + 1;
648
+ modal.selectedTargetIndex = idx;
649
+ this.updateDisplay();
650
+ return;
651
+ case "return":
652
+ case "enter":
653
+ this.submitMove();
654
+ return;
655
+ default:
656
+ return;
657
+ }
658
+ }
659
+
660
+ if (modal.type === "search") {
661
+ const results = modal.searchResults ?? [];
662
+ const len = results.length;
663
+ let idx = modal.selectedResultIndex ?? 0;
664
+
665
+ switch (keyName) {
666
+ case "escape":
667
+ this.closeModal();
668
+ return;
669
+ case "up":
670
+ case "k":
671
+ idx = idx <= 0 ? 0 : idx - 1;
672
+ modal.selectedResultIndex = idx;
673
+ this.updateDisplay();
674
+ return;
675
+ case "down":
676
+ case "j":
677
+ idx = idx >= len - 1 ? len - 1 : idx + 1;
678
+ modal.selectedResultIndex = idx;
679
+ this.updateDisplay();
680
+ return;
681
+ case "return":
682
+ case "enter":
683
+ if (len > 0 && results[idx]) {
684
+ navigateToNode(this.state, results[idx].nodeId);
685
+ this.closeModal();
686
+ this.updateDisplay();
687
+ }
688
+ return;
689
+ case "backspace":
690
+ case "delete":
691
+ modal.inputValue = (modal.inputValue ?? "").slice(0, -1);
692
+ this.runSearchAndUpdateResults();
693
+ this.updateDisplay();
694
+ return;
695
+ default:
696
+ if (key.sequence && key.sequence.length === 1 && key.sequence.charCodeAt(0) >= 32) {
697
+ modal.inputValue = (modal.inputValue ?? "") + key.sequence;
698
+ this.runSearchAndUpdateResults();
699
+ this.updateDisplay();
700
+ }
701
+ return;
702
+ }
703
+ }
704
+
493
705
  switch (keyName) {
494
706
  case "escape":
495
707
  this.closeModal();
@@ -630,6 +842,33 @@ export class TUIApp {
630
842
  this.showFeedback("Deleted");
631
843
  }
632
844
 
845
+ private async submitMove(): Promise<void> {
846
+ const modal = this.state.modalState;
847
+ if (!modal || modal.type !== "move" || !modal.nodeIdToMove) {
848
+ this.closeModal();
849
+ return;
850
+ }
851
+
852
+ const targets = modal.moveTargets ?? [];
853
+ const idx = modal.selectedTargetIndex ?? 0;
854
+ const target = targets[idx];
855
+ if (!target) {
856
+ this.closeModal();
857
+ return;
858
+ }
859
+
860
+ const ok = moveNode(this.state.project.root, modal.nodeIdToMove, target.nodeId);
861
+ if (!ok) {
862
+ this.showFeedback("Move failed");
863
+ return;
864
+ }
865
+
866
+ navigateToNode(this.state, modal.nodeIdToMove);
867
+ await this.save();
868
+ this.closeModal();
869
+ this.showFeedback("Moved");
870
+ }
871
+
633
872
  private async toggleDone(): Promise<void> {
634
873
  const selected = getSelectedNode(this.state);
635
874
  if (!selected) {
@@ -723,6 +962,8 @@ function disableMouseTracking(): void {
723
962
  export async function startTUI(project: Project): Promise<void> {
724
963
  disableMouseTracking();
725
964
 
965
+ const config = await loadConfig();
966
+
726
967
  const renderer = await createCliRenderer({
727
968
  exitOnCtrlC: true,
728
969
  });
@@ -745,7 +986,7 @@ export async function startTUI(project: Project): Promise<void> {
745
986
  restoreTerminal();
746
987
  });
747
988
 
748
- const app = new TUIApp(renderer, project);
989
+ const app = new TUIApp(renderer, project, config.viewMode);
749
990
 
750
991
  try {
751
992
  await app.start();
@@ -1,4 +1,4 @@
1
- import { findNode } from "../data/operations";
1
+ import { findNode, getNodePath } from "../data/operations";
2
2
  import type { AppState, Node } from "../data/types";
3
3
 
4
4
  export interface NavigationResult {
@@ -7,26 +7,30 @@ export interface NavigationResult {
7
7
  }
8
8
 
9
9
  export function getCurrentList(state: AppState): Node[] {
10
+ let children: Node[];
10
11
  if (state.navigationStack.length === 0) {
11
- return state.project.root.children;
12
- }
13
-
14
- const parentId = state.navigationStack[state.navigationStack.length - 1];
15
- const parent = findNode(state.project.root, parentId);
16
-
17
- if (!parent) {
18
- return state.project.root.children;
12
+ children = state.project.root.children;
13
+ } else {
14
+ const parentId = state.navigationStack[state.navigationStack.length - 1];
15
+ const parent = findNode(state.project.root, parentId);
16
+ if (!parent) {
17
+ children = state.project.root.children;
18
+ } else {
19
+ children = parent.children;
20
+ }
19
21
  }
20
-
21
- return parent.children;
22
+ return [...children].sort((a, b) => b.children.length - a.children.length);
22
23
  }
23
24
 
24
25
  export function getSelectedNode(state: AppState): Node | null {
25
26
  const list = getCurrentList(state);
26
- if (list.length === 0 || state.selectedIndex >= list.length) {
27
- return null;
28
- }
29
- return list[state.selectedIndex] || null;
27
+ if (list.length === 0) return null;
28
+ const index =
29
+ state.selectedNodeId != null
30
+ ? list.findIndex((n) => n.id === state.selectedNodeId)
31
+ : state.selectedIndex;
32
+ const i = index >= 0 ? index : 0;
33
+ return list[i] ?? null;
30
34
  }
31
35
 
32
36
  export function getCurrentParent(state: AppState): Node | null {
@@ -58,11 +62,18 @@ export function moveUp(state: AppState): NavigationResult {
58
62
  return { success: false, feedbackMessage: "List is empty" };
59
63
  }
60
64
 
61
- if (state.selectedIndex <= 0) {
65
+ const index =
66
+ state.selectedNodeId != null
67
+ ? list.findIndex((n) => n.id === state.selectedNodeId)
68
+ : state.selectedIndex;
69
+ const currentIndex = index >= 0 ? index : 0;
70
+
71
+ if (currentIndex <= 0) {
62
72
  return { success: false, feedbackMessage: "At top" };
63
73
  }
64
74
 
65
- state.selectedIndex--;
75
+ state.selectedIndex = currentIndex - 1;
76
+ state.selectedNodeId = list[state.selectedIndex]?.id ?? null;
66
77
  return { success: true };
67
78
  }
68
79
 
@@ -73,11 +84,18 @@ export function moveDown(state: AppState): NavigationResult {
73
84
  return { success: false, feedbackMessage: "List is empty" };
74
85
  }
75
86
 
76
- if (state.selectedIndex >= list.length - 1) {
87
+ const index =
88
+ state.selectedNodeId != null
89
+ ? list.findIndex((n) => n.id === state.selectedNodeId)
90
+ : state.selectedIndex;
91
+ const currentIndex = index >= 0 ? index : 0;
92
+
93
+ if (currentIndex >= list.length - 1) {
77
94
  return { success: false, feedbackMessage: "At bottom" };
78
95
  }
79
96
 
80
- state.selectedIndex++;
97
+ state.selectedIndex = currentIndex + 1;
98
+ state.selectedNodeId = list[state.selectedIndex]?.id ?? null;
81
99
  return { success: true };
82
100
  }
83
101
 
@@ -89,7 +107,9 @@ export function diveIn(state: AppState): NavigationResult {
89
107
  }
90
108
 
91
109
  state.navigationStack.push(selected.id);
110
+ const list = getCurrentList(state);
92
111
  state.selectedIndex = 0;
112
+ state.selectedNodeId = list[0]?.id ?? null;
93
113
  return { success: true };
94
114
  }
95
115
 
@@ -107,12 +127,15 @@ export function goBack(state: AppState): NavigationResult {
107
127
  const previousIndex = list.findIndex((node) => node.id === lastId);
108
128
 
109
129
  state.selectedIndex = previousIndex >= 0 ? previousIndex : 0;
130
+ state.selectedNodeId = list[state.selectedIndex]?.id ?? lastId;
110
131
  return { success: true };
111
132
  }
112
133
 
113
134
  export function initializeNavigation(state: AppState): void {
114
135
  state.navigationStack = [];
136
+ const list = getCurrentList(state);
115
137
  state.selectedIndex = 0;
138
+ state.selectedNodeId = list[0]?.id ?? null;
116
139
  }
117
140
 
118
141
  export function adjustSelectionAfterDelete(state: AppState, deletedIndex: number): void {
@@ -120,16 +143,26 @@ export function adjustSelectionAfterDelete(state: AppState, deletedIndex: number
120
143
 
121
144
  if (list.length === 0) {
122
145
  state.selectedIndex = 0;
146
+ state.selectedNodeId = null;
123
147
  return;
124
148
  }
125
149
 
126
- if (deletedIndex <= state.selectedIndex && state.selectedIndex > 0) {
127
- state.selectedIndex--;
150
+ const index =
151
+ state.selectedNodeId != null
152
+ ? list.findIndex((n) => n.id === state.selectedNodeId)
153
+ : state.selectedIndex;
154
+ let newIndex = index >= 0 ? index : 0;
155
+
156
+ if (deletedIndex <= newIndex && newIndex > 0) {
157
+ newIndex--;
128
158
  }
129
159
 
130
- if (state.selectedIndex >= list.length) {
131
- state.selectedIndex = list.length - 1;
160
+ if (newIndex >= list.length) {
161
+ newIndex = list.length - 1;
132
162
  }
163
+
164
+ state.selectedIndex = newIndex;
165
+ state.selectedNodeId = list[newIndex]?.id ?? null;
133
166
  }
134
167
 
135
168
  export function ensureValidSelection(state: AppState): void {
@@ -137,14 +170,43 @@ export function ensureValidSelection(state: AppState): void {
137
170
 
138
171
  if (list.length === 0) {
139
172
  state.selectedIndex = 0;
173
+ state.selectedNodeId = null;
140
174
  return;
141
175
  }
142
176
 
143
- if (state.selectedIndex < 0) {
144
- state.selectedIndex = 0;
177
+ let index: number;
178
+ if (state.selectedNodeId != null) {
179
+ index = list.findIndex((n) => n.id === state.selectedNodeId);
180
+ if (index < 0) index = 0;
181
+ } else {
182
+ index = state.selectedIndex;
145
183
  }
146
184
 
147
- if (state.selectedIndex >= list.length) {
148
- state.selectedIndex = list.length - 1;
185
+ if (index < 0) index = 0;
186
+ if (index >= list.length) index = list.length - 1;
187
+
188
+ state.selectedIndex = index;
189
+ state.selectedNodeId = list[index]?.id ?? null;
190
+ }
191
+
192
+ export function navigateToNode(state: AppState, nodeId: string): void {
193
+ const path = getNodePath(state.project.root, nodeId);
194
+ if (!path || path.length === 0) return;
195
+
196
+ if (path.length === 1) {
197
+ state.navigationStack = [];
198
+ state.selectedIndex = 0;
199
+ state.selectedNodeId = path[0]?.id ?? null;
200
+ return;
149
201
  }
202
+
203
+ const parent = path[path.length - 2];
204
+ const node = path[path.length - 1];
205
+ if (!parent || !node) return;
206
+
207
+ state.navigationStack = path.slice(1, -1).map((n) => n.id);
208
+ const list = getCurrentList(state);
209
+ const index = list.findIndex((n) => n.id === node.id);
210
+ state.selectedIndex = index >= 0 ? index : 0;
211
+ state.selectedNodeId = node.id;
150
212
  }
@@ -0,0 +1,51 @@
1
+ import { platform } from "node:os";
2
+
3
+ export async function writeToClipboard(text: string): Promise<boolean> {
4
+ const plat = platform();
5
+ let cmd: string;
6
+ let args: string[];
7
+
8
+ if (plat === "darwin") {
9
+ cmd = "pbcopy";
10
+ args = [];
11
+ } else if (plat === "win32") {
12
+ cmd = "clip";
13
+ args = [];
14
+ } else {
15
+ // Linux: try xclip first, then xsel
16
+ cmd = "xclip";
17
+ args = ["-selection", "clipboard"];
18
+ }
19
+
20
+ try {
21
+ const proc = Bun.spawn([cmd, ...args], {
22
+ stdin: "pipe",
23
+ stdout: "ignore",
24
+ stderr: "pipe",
25
+ });
26
+ proc.stdin.write(text);
27
+ proc.stdin.end();
28
+ const exit = await proc.exited;
29
+ if (exit === 0) return true;
30
+ } catch {
31
+ // pbcopy/clip/xclip failed
32
+ }
33
+
34
+ if (plat !== "darwin" && plat !== "win32") {
35
+ try {
36
+ const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
37
+ stdin: "pipe",
38
+ stdout: "ignore",
39
+ stderr: "pipe",
40
+ });
41
+ proc.stdin.write(text);
42
+ proc.stdin.end();
43
+ const exit = await proc.exited;
44
+ if (exit === 0) return true;
45
+ } catch {
46
+ // xsel failed
47
+ }
48
+ }
49
+
50
+ return false;
51
+ }
@@ -12,6 +12,10 @@ export function getDataDir(): string {
12
12
  }
13
13
  }
14
14
 
15
+ export function getConfigPath(): string {
16
+ return join(getDataDir(), "depthfirst", "config.json");
17
+ }
18
+
15
19
  export function getProjectsDir(): string {
16
20
  return join(getDataDir(), "depthfirst", "projects");
17
21
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "2.1.7";
1
+ export const VERSION = "2.1.9";