gitforest 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/.bunignore +7 -0
  2. package/.github/workflows/ci.yml +73 -0
  3. package/CLAUDE.md +111 -0
  4. package/CONTRIBUTING.md +145 -0
  5. package/README.md +168 -0
  6. package/bun.lock +267 -0
  7. package/bunfig.toml +15 -0
  8. package/cli +0 -0
  9. package/config/gitforest.example.yaml +94 -0
  10. package/docs/ai/IMPROVEMENT_PLAN.md +341 -0
  11. package/docs/ai/VERIFICATION_REPORT.md +87 -0
  12. package/docs/ai/architecture.md +169 -0
  13. package/docs/ai/checks/check-2025-12-02-tests.md +40 -0
  14. package/docs/ai/checks/check-2025-12-02.md +55 -0
  15. package/docs/ai/checks/test-verification-report.md +85 -0
  16. package/docs/ai/implementation-guide.md +776 -0
  17. package/docs/ai/research/gitty-codebase-analysis.md +221 -0
  18. package/docs/ai/tickets/GENERAL-sitrep.md +30 -0
  19. package/docs/ai/tickets/TASK-database-tests-sitrep.md +25 -0
  20. package/docs/ai/tickets/TASK-deprecated-functions-sitrep.md +28 -0
  21. package/docs/ai/tickets/TASK-detail-modal-sitrep.md +28 -0
  22. package/docs/ai/tickets/TASK-filter-overlay-sitrep.md +24 -0
  23. package/docs/ai/tickets/TASK-github-service-sitrep.md +32 -0
  24. package/docs/ai/tickets/TASK-github-token-sitrep.md +51 -0
  25. package/docs/ai/tickets/TASK-hascommits-sitrep.md +35 -0
  26. package/docs/ai/tickets/TASK-keybindings-sitrep.md +26 -0
  27. package/docs/ai/tickets/TASK-layout-sitrep.md +25 -0
  28. package/docs/ai/tickets/TASK-markdown-sitrep.md +28 -0
  29. package/docs/ai/tickets/TASK-project-item-sitrep.md +79 -0
  30. package/docs/ai/tickets/TASK-sitrep.md +28 -0
  31. package/docs/ai/tickets/TASK-state-sitrep.md +26 -0
  32. package/docs/ai/tickets/TASK-types-sitrep.md +25 -0
  33. package/docs/ai/tickets/TASK-unified-item-fix-sitrep.md +26 -0
  34. package/docs/ai/tickets/TKT-001-sitrep.md +24 -0
  35. package/docs/ai/tickets/TKT-002-sitrep.md +25 -0
  36. package/docs/ai/tickets/TKT-003-git-service-refactoring-complete.md +46 -0
  37. package/docs/ai/tickets/TKT-003-git-service-refactoring-plan.md +135 -0
  38. package/docs/ai/tickets/TKT-003-sitrep.md +26 -0
  39. package/docs/ai/tickets/TKT-004-sitrep.md +27 -0
  40. package/docs/ai/tickets/TKT-005-sitrep.md +25 -0
  41. package/docs/ai/tickets/TKT-006-sitrep.md +26 -0
  42. package/docs/ai/tickets/TKT-007-sitrep.md +30 -0
  43. package/docs/ai/tickets/TKT-008-sitrep.md +32 -0
  44. package/docs/ai/tickets/TKT-009-sitrep.md +27 -0
  45. package/docs/ai/tickets/TKT-010-sitrep.md +27 -0
  46. package/docs/ai/tickets/TKT-011-sitrep.md +26 -0
  47. package/docs/ai/tickets/TKT-012-sitrep.md +25 -0
  48. package/docs/ai/tickets/sitreps/TASK-actions-sitrep.md +28 -0
  49. package/docs/ai/tickets/sitreps/TASK-actions-test-sitrep.md +25 -0
  50. package/docs/ai/tickets/sitreps/TASK-app-integration-sitrep.md +25 -0
  51. package/docs/ai/tickets/sitreps/TASK-background-fetch-sitrep.md +24 -0
  52. package/docs/ai/tickets/sitreps/TASK-background-fetch-test-sitrep.md +29 -0
  53. package/docs/ai/tickets/sitreps/TASK-batch-tests-sitrep.md +29 -0
  54. package/docs/ai/tickets/sitreps/TASK-bun-test-sitrep.md +26 -0
  55. package/docs/ai/tickets/sitreps/TASK-cache-tests-sitrep.md +30 -0
  56. package/docs/ai/tickets/sitreps/TASK-cli-tests-sitrep.md +28 -0
  57. package/docs/ai/tickets/sitreps/TASK-clone-error-handling-sitrep.md +26 -0
  58. package/docs/ai/tickets/sitreps/TASK-commands-tests-sitrep.md +25 -0
  59. package/docs/ai/tickets/sitreps/TASK-component-tests-1-sitrep.md +30 -0
  60. package/docs/ai/tickets/sitreps/TASK-configloader-tests-sitrep.md +25 -0
  61. package/docs/ai/tickets/sitreps/TASK-confirm-dialog-test-sitrep.md +29 -0
  62. package/docs/ai/tickets/sitreps/TASK-coverage-sitrep.md +95 -0
  63. package/docs/ai/tickets/sitreps/TASK-database-tests-summary.md +61 -0
  64. package/docs/ai/tickets/sitreps/TASK-error-boundary-sitrep.md +30 -0
  65. package/docs/ai/tickets/sitreps/TASK-error-tests-sitrep.md +27 -0
  66. package/docs/ai/tickets/sitreps/TASK-errors-tests-sitrep.md +25 -0
  67. package/docs/ai/tickets/sitreps/TASK-extract-reducer-sitrep.md +27 -0
  68. package/docs/ai/tickets/sitreps/TASK-filter-overlay-test-sitrep.md +25 -0
  69. package/docs/ai/tickets/sitreps/TASK-final-verification-sitrep.md +28 -0
  70. package/docs/ai/tickets/sitreps/TASK-fix-all-tests-sitrep.md +25 -0
  71. package/docs/ai/tickets/sitreps/TASK-fix-hooks-sitrep.md +26 -0
  72. package/docs/ai/tickets/sitreps/TASK-fix-remaining-tests-sitrep.md +25 -0
  73. package/docs/ai/tickets/sitreps/TASK-fix-test-failures-sitrep.md +26 -0
  74. package/docs/ai/tickets/sitreps/TASK-fix-tests-sitrep.md +24 -0
  75. package/docs/ai/tickets/sitreps/TASK-formatters-tests-sitrep.md +25 -0
  76. package/docs/ai/tickets/sitreps/TASK-git-timeouts-sitrep.md +29 -0
  77. package/docs/ai/tickets/sitreps/TASK-github-cache-test-sitrep.md +25 -0
  78. package/docs/ai/tickets/sitreps/TASK-githubcli-tests-sitrep.md +24 -0
  79. package/docs/ai/tickets/sitreps/TASK-gitstatus-tests-sitrep.md +24 -0
  80. package/docs/ai/tickets/sitreps/TASK-hooks-isolation-sitrep.md +27 -0
  81. package/docs/ai/tickets/sitreps/TASK-keybindings-tests-sitrep.md +25 -0
  82. package/docs/ai/tickets/sitreps/TASK-layout-tests-sitrep.md +25 -0
  83. package/docs/ai/tickets/sitreps/TASK-mock-factories-sitrep.md +27 -0
  84. package/docs/ai/tickets/sitreps/TASK-modal-tests-sitrep.md +32 -0
  85. package/docs/ai/tickets/sitreps/TASK-processbatch-fix-sitrep.md +27 -0
  86. package/docs/ai/tickets/sitreps/TASK-projectlist-tests-sitrep.md +30 -0
  87. package/docs/ai/tickets/sitreps/TASK-projectutils-tests-sitrep.md +25 -0
  88. package/docs/ai/tickets/sitreps/TASK-scanner-tests-sitrep.md +29 -0
  89. package/docs/ai/tickets/sitreps/TASK-select-all-sitrep.md +25 -0
  90. package/docs/ai/tickets/sitreps/TASK-shell-error-handling-sitrep.md +27 -0
  91. package/docs/ai/tickets/sitreps/TASK-store-tests-sitrep.md +25 -0
  92. package/docs/ai/tickets/sitreps/TASK-test-fixes-sitrep.md +26 -0
  93. package/docs/ai/tickets/sitreps/TASK-test-summary-sitrep.md +25 -0
  94. package/docs/ai/tickets/sitreps/TASK-test-verification-sitrep.md +27 -0
  95. package/docs/ai/tickets/sitreps/TASK-testsuite-sitrep.md +75 -0
  96. package/docs/ai/tickets/sitreps/TASK-unified-reducer-tests-sitrep.md +29 -0
  97. package/docs/ai/tickets/sitreps/TASK-unified-repos-test-sitrep.md +29 -0
  98. package/docs/ai/tickets/sitreps/TASK-unified-tests-sitrep.md +25 -0
  99. package/docs/ai/tickets/sitreps/TASK-useprojects-tests-sitrep.md +25 -0
  100. package/docs/ai/tickets/sitreps/TASK-utility-tests-sitrep.md +32 -0
  101. package/docs/ai/tickets/sitreps/TKT-003-git-service-refactoring-sitrep.md +64 -0
  102. package/docs/ai/tkt-001-fix-database-error.md +217 -0
  103. package/docs/ai/ui-enhancement-plan.md +562 -0
  104. package/package.json +50 -0
  105. package/src/app.tsx +43 -0
  106. package/src/cli/config.ts +94 -0
  107. package/src/cli/formatters.ts +632 -0
  108. package/src/cli/index.ts +583 -0
  109. package/src/components/CloneDialog.tsx +137 -0
  110. package/src/components/ColumnHeader.tsx +128 -0
  111. package/src/components/CommandPalette.tsx +120 -0
  112. package/src/components/ConfirmDialog.tsx +105 -0
  113. package/src/components/ErrorBoundary.tsx +128 -0
  114. package/src/components/FilterBar.tsx +71 -0
  115. package/src/components/FilterOptionsOverlay.tsx +131 -0
  116. package/src/components/HelpOverlay.tsx +120 -0
  117. package/src/components/Layout.tsx +379 -0
  118. package/src/components/MarkdownRenderer.tsx +127 -0
  119. package/src/components/ProgressBar.tsx +53 -0
  120. package/src/components/ProjectItem.tsx +143 -0
  121. package/src/components/ProjectList.tsx +90 -0
  122. package/src/components/RepoDetailModal.tsx +367 -0
  123. package/src/components/StatusBar.tsx +188 -0
  124. package/src/components/UnifiedProjectItem.tsx +436 -0
  125. package/src/components/ViewModeIndicator.tsx +37 -0
  126. package/src/components/onboarding/CompleteStep.tsx +82 -0
  127. package/src/components/onboarding/DirectoriesStep.test.tsx +52 -0
  128. package/src/components/onboarding/DirectoriesStep.tsx +847 -0
  129. package/src/components/onboarding/DirectoriesStep.unit.test.ts +345 -0
  130. package/src/components/onboarding/GitHubAuthStep.tsx +268 -0
  131. package/src/components/onboarding/OnboardingWizard.tsx +130 -0
  132. package/src/components/onboarding/WelcomeStep.tsx +69 -0
  133. package/src/config/loader.ts +263 -0
  134. package/src/config/onboarding.ts +67 -0
  135. package/src/constants.ts +96 -0
  136. package/src/db/index.ts +147 -0
  137. package/src/db/schema.ts +70 -0
  138. package/src/git/commands.ts +283 -0
  139. package/src/git/index.ts +2 -0
  140. package/src/git/operations.ts +93 -0
  141. package/src/git/service.ts +539 -0
  142. package/src/git/status.ts +84 -0
  143. package/src/git/types.ts +5 -0
  144. package/src/github/auth.ts +311 -0
  145. package/src/github/cache.ts +231 -0
  146. package/src/github/cli.ts +22 -0
  147. package/src/github/unified.ts +415 -0
  148. package/src/hooks/useBackgroundFetch.ts +76 -0
  149. package/src/hooks/useConfirmDialogActions.ts +120 -0
  150. package/src/hooks/useKeyBindings.ts +656 -0
  151. package/src/hooks/useProjects.ts +47 -0
  152. package/src/hooks/useUnifiedRepos.ts +317 -0
  153. package/src/index.tsx +494 -0
  154. package/src/operations/batch.ts +280 -0
  155. package/src/operations/commands.ts +140 -0
  156. package/src/operations/index.ts +37 -0
  157. package/src/scanner/index.ts +424 -0
  158. package/src/scanner/markers.ts +43 -0
  159. package/src/scanner/submodules.ts +61 -0
  160. package/src/services/git.ts +484 -0
  161. package/src/services/github.ts +676 -0
  162. package/src/services/index.ts +28 -0
  163. package/src/services/types.ts +99 -0
  164. package/src/state/actions.ts +175 -0
  165. package/src/state/reducer.ts +294 -0
  166. package/src/state/store.tsx +216 -0
  167. package/src/state/types.ts +8 -0
  168. package/src/types/index.ts +383 -0
  169. package/src/ui/theme.ts +44 -0
  170. package/src/utils/array.ts +14 -0
  171. package/src/utils/debug.ts +38 -0
  172. package/src/utils/errors.ts +17 -0
  173. package/src/utils/index.ts +8 -0
  174. package/src/utils/markdown.ts +230 -0
  175. package/src/utils/project-utils.ts +129 -0
  176. package/src/utils/rate-limiter.ts +134 -0
  177. package/src/utils/retry.ts +147 -0
  178. package/src/utils/timeout.ts +56 -0
  179. package/test/integration/app.isolated.tsx +240 -0
  180. package/test/integration/cli-commands.test.ts +287 -0
  181. package/test/integration/cli-validation.test.ts +264 -0
  182. package/test/integration/git-operations.test.ts +218 -0
  183. package/test/integration/scanner.test.ts +228 -0
  184. package/test/preload.ts +18 -0
  185. package/test/unit/cli/commands.test.ts +13 -0
  186. package/test/unit/cli/formatters.test.ts +1116 -0
  187. package/test/unit/cli/github-commands.test.ts +12 -0
  188. package/test/unit/components/CloneDialog.test.tsx +240 -0
  189. package/test/unit/components/ColumnHeader.test.tsx +128 -0
  190. package/test/unit/components/CommandPalette.test.tsx +355 -0
  191. package/test/unit/components/ConfirmDialog.test.tsx +111 -0
  192. package/test/unit/components/ErrorBoundary.test.tsx +139 -0
  193. package/test/unit/components/FilterBar.test.tsx +43 -0
  194. package/test/unit/components/FilterOptionsOverlay.test.tsx +197 -0
  195. package/test/unit/components/HelpOverlay.test.tsx +90 -0
  196. package/test/unit/components/Layout.test.tsx +328 -0
  197. package/test/unit/components/MarkdownRenderer.test.tsx +45 -0
  198. package/test/unit/components/ProgressBar.test.tsx +138 -0
  199. package/test/unit/components/ProjectItem.test.tsx +182 -0
  200. package/test/unit/components/ProjectList.test.tsx +311 -0
  201. package/test/unit/components/RepoDetailModal.test.tsx +445 -0
  202. package/test/unit/components/StatusBar.test.tsx +112 -0
  203. package/test/unit/components/UnifiedProjectItem.test.tsx +618 -0
  204. package/test/unit/components/ViewModeIndicator.test.tsx +137 -0
  205. package/test/unit/components/test-utils.tsx +63 -0
  206. package/test/unit/config/loader.test.ts +692 -0
  207. package/test/unit/db/database.test.ts +978 -0
  208. package/test/unit/db/index.test.ts +314 -0
  209. package/test/unit/fixtures/setup.ts +186 -0
  210. package/test/unit/git/commands-untested.test.ts +205 -0
  211. package/test/unit/git/commands.test.ts +269 -0
  212. package/test/unit/git/operations.test.ts +322 -0
  213. package/test/unit/git/status.test.ts +219 -0
  214. package/test/unit/github/auth.test.ts +317 -0
  215. package/test/unit/github/cache.test.ts +1028 -0
  216. package/test/unit/github/cli.test.ts +135 -0
  217. package/test/unit/github/unified.test.ts +1201 -0
  218. package/test/unit/graceful-shutdown.test.ts +83 -0
  219. package/test/unit/hooks/useBackgroundFetch.test.tsx +239 -0
  220. package/test/unit/hooks/useConfirmDialogActions.test.tsx +81 -0
  221. package/test/unit/hooks/useKeyBindings.isolated.ts +715 -0
  222. package/test/unit/hooks/useProjects.test.tsx +186 -0
  223. package/test/unit/hooks/useUnifiedRepos-simple.test.tsx +115 -0
  224. package/test/unit/hooks/useUnifiedRepos.test.tsx +177 -0
  225. package/test/unit/mocks/config.ts +109 -0
  226. package/test/unit/mocks/git-service.ts +274 -0
  227. package/test/unit/mocks/github-service.ts +250 -0
  228. package/test/unit/mocks/index.ts +72 -0
  229. package/test/unit/mocks/project.ts +148 -0
  230. package/test/unit/mocks/state-mocks.ts +187 -0
  231. package/test/unit/mocks/unified.ts +169 -0
  232. package/test/unit/operations/batch.test.ts +216 -0
  233. package/test/unit/operations/commands.test.ts +550 -0
  234. package/test/unit/scanner/errors.test.ts +297 -0
  235. package/test/unit/scanner/index.test.ts +1011 -0
  236. package/test/unit/scanner/markers.test.ts +150 -0
  237. package/test/unit/scanner/submodules.test.ts +99 -0
  238. package/test/unit/services/git-errors.test.ts +190 -0
  239. package/test/unit/services/git.test.ts +442 -0
  240. package/test/unit/services/github-errors.test.ts +293 -0
  241. package/test/unit/services/github.test.ts +200 -0
  242. package/test/unit/state/actions.test.ts +217 -0
  243. package/test/unit/state/reducer.test.ts +745 -0
  244. package/test/unit/state/store.test.tsx +711 -0
  245. package/test/unit/types/commands.test.ts +220 -0
  246. package/test/unit/types/schema.test.ts +179 -0
  247. package/test/unit/utils/array.test.ts +73 -0
  248. package/test/unit/utils/debug.test.ts +23 -0
  249. package/test/unit/utils/errors.test.ts +295 -0
  250. package/test/unit/utils/markdown.test.ts +163 -0
  251. package/test/unit/utils/project-utils.test.ts +756 -0
  252. package/test/unit/utils/rate-limiter.test.ts +256 -0
  253. package/test/unit/utils/retry.test.ts +165 -0
  254. package/test/unit/utils/strip-ansi.ts +13 -0
  255. package/test/unit/utils/timeout.test.ts +93 -0
  256. package/tsconfig.json +29 -0
@@ -0,0 +1,847 @@
1
+ import { useState, useEffect } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { existsSync, statSync, readdirSync } from "fs";
4
+ import { homedir } from "os";
5
+ import { join, dirname, basename, relative } from "path";
6
+
7
+ export interface DirectoriesStepProps {
8
+ directories: Array<{ path: string; maxDepth: number; label?: string }>;
9
+ onComplete: (directories: Array<{ path: string; maxDepth: number; label?: string }>) => void;
10
+ onBack: () => void;
11
+ onCancel: () => void;
12
+ }
13
+
14
+ interface DirectoryItem {
15
+ path: string;
16
+ maxDepth: number;
17
+ label?: string;
18
+ valid: boolean;
19
+ error?: string;
20
+ }
21
+
22
+ interface InputState {
23
+ maxDepth: string;
24
+ label: string;
25
+ showLabelInput: boolean;
26
+ }
27
+
28
+ interface SimplifiedBrowserState {
29
+ // Current working directory
30
+ currentPath: string;
31
+
32
+ // Contents of currentPath
33
+ entries: Array<{ name: string; fullPath: string }>;
34
+
35
+ // What user is typing
36
+ inputBuffer: string;
37
+
38
+ // Completions for current input
39
+ completions: string[];
40
+
41
+ // Index for Tab cycling
42
+ completionIndex: number;
43
+
44
+ // Scroll offset for folder list
45
+ scrollOffset: number;
46
+
47
+ // Selected folder index in the filtered list
48
+ selectedFolderIndex: number;
49
+
50
+ error: string | null;
51
+ }
52
+
53
+ export function validateDirectoryPath(path: string): { valid: boolean; error?: string } {
54
+ if (!path) {
55
+ return { valid: false, error: "Path is required" };
56
+ }
57
+
58
+ const expanded = path.replace(/^~/, homedir());
59
+
60
+ if (!existsSync(expanded)) {
61
+ return { valid: false, error: "Path does not exist" };
62
+ }
63
+
64
+ try {
65
+ const stat = statSync(expanded);
66
+ if (!stat.isDirectory()) {
67
+ return { valid: false, error: "Not a directory" };
68
+ }
69
+ } catch {
70
+ return { valid: false, error: "Cannot access path" };
71
+ }
72
+
73
+ return { valid: true };
74
+ }
75
+
76
+ /**
77
+ * Read directory contents, filtering to directories only
78
+ */
79
+ export function readDirectory(path: string): Array<{ name: string; fullPath: string }> {
80
+ try {
81
+ const entries = readdirSync(path, { withFileTypes: true });
82
+ return entries
83
+ .filter((e) => {
84
+ if (e.name.startsWith(".")) return false;
85
+
86
+ // Regular directory check
87
+ if (e.isDirectory()) return true;
88
+
89
+ // Check if symlink points to a directory
90
+ if (e.isSymbolicLink()) {
91
+ try {
92
+ const fullPath = join(path, e.name);
93
+ return statSync(fullPath).isDirectory();
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ return false;
100
+ })
101
+ .map((e) => ({
102
+ name: e.name,
103
+ fullPath: join(path, e.name),
104
+ }))
105
+ .sort((a, b) => a.name.localeCompare(b.name));
106
+ } catch {
107
+ return [];
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Get path completions based on input
113
+ */
114
+ export function getCompletions(input: string, currentPath: string): string[] {
115
+ // Strip trailing slash for proper dirname/basename handling
116
+ const normalizedInput = input.endsWith("/") ? input.slice(0, -1) : input;
117
+ const expanded = normalizedInput.replace(/^~/, homedir());
118
+
119
+ // If input starts with /, it's an absolute path
120
+ if (input.startsWith("/")) {
121
+ const dir = dirname(expanded || "/");
122
+ const prefix = basename(expanded);
123
+ const entries = readDirectory(dir);
124
+ return entries
125
+ .filter((e) => e.name.toLowerCase().startsWith(prefix.toLowerCase()))
126
+ .map((e) => join(dir, e.name));
127
+ }
128
+
129
+ // Relative or home-based path
130
+ const basePath = expanded.startsWith("/") ? "/" : currentPath;
131
+ const dir = join(basePath, dirname(expanded || "."));
132
+ const prefix = basename(expanded);
133
+
134
+ try {
135
+ const entries = readDirectory(dir);
136
+ return entries
137
+ .filter((e) => e.name.toLowerCase().startsWith(prefix.toLowerCase()))
138
+ .map((e) => join(dir, e.name));
139
+ } catch {
140
+ return [];
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Format path for display (replace home with ~)
146
+ */
147
+ export function formatDisplayPath(path: string): string {
148
+ const home = homedir();
149
+ if (path.startsWith(home)) {
150
+ return "~" + path.slice(home.length);
151
+ }
152
+ return path;
153
+ }
154
+
155
+ /**
156
+ * Hybrid directory browser with typing + navigation
157
+ */
158
+ interface HybridDirectoryBrowserProps {
159
+ startingPath: string;
160
+ onSelect: (path: string) => void;
161
+ onCancel: () => void;
162
+ }
163
+
164
+ export function HybridDirectoryBrowser({ startingPath, onSelect, onCancel }: HybridDirectoryBrowserProps) {
165
+ const MAX_DISPLAY_FOLDERS = 10;
166
+
167
+ const [browser, setBrowser] = useState<SimplifiedBrowserState>({
168
+ currentPath: startingPath,
169
+ entries: readDirectory(startingPath),
170
+ inputBuffer: "~",
171
+ completions: [],
172
+ completionIndex: 0,
173
+ scrollOffset: 0,
174
+ selectedFolderIndex: 0,
175
+ error: null,
176
+ });
177
+
178
+ // Update completions when input changes
179
+ useEffect(() => {
180
+ if (browser.inputBuffer.length > 0) {
181
+ const matches = getCompletions(browser.inputBuffer, browser.currentPath);
182
+ setBrowser((prev) => ({
183
+ ...prev,
184
+ completions: matches,
185
+ completionIndex: 0,
186
+ }));
187
+ } else {
188
+ setBrowser((prev) => ({
189
+ ...prev,
190
+ completions: [],
191
+ completionIndex: 0,
192
+ }));
193
+ }
194
+ }, [browser.inputBuffer, browser.currentPath]);
195
+
196
+ // Update currentPath when input is a valid directory
197
+ useEffect(() => {
198
+ if (browser.inputBuffer.length > 0) {
199
+ // Expand ~ for validation
200
+ const expanded = browser.inputBuffer.replace(/^~/, homedir());
201
+ // Check if it's a valid directory
202
+ if (existsSync(expanded) && statSync(expanded).isDirectory()) {
203
+ setBrowser((prev) => ({
204
+ ...prev,
205
+ currentPath: expanded,
206
+ entries: readDirectory(expanded),
207
+ }));
208
+ }
209
+ } else {
210
+ // When input is empty, default to home directory
211
+ setBrowser((prev) => ({
212
+ ...prev,
213
+ currentPath: startingPath,
214
+ entries: readDirectory(startingPath),
215
+ }));
216
+ }
217
+ }, [browser.inputBuffer, startingPath]);
218
+
219
+ // Reset scroll offset and selection when currentPath changes
220
+ useEffect(() => {
221
+ setBrowser((prev) => ({
222
+ ...prev,
223
+ scrollOffset: 0,
224
+ selectedFolderIndex: 0,
225
+ }));
226
+ }, [browser.currentPath]);
227
+
228
+ useInput((input, key) => {
229
+ // Esc: go back to parent directory or cancel
230
+ if (key.escape) {
231
+ // If input ends with / (navigated into a directory), go back to parent
232
+ if (browser.inputBuffer.endsWith("/")) {
233
+ const parentPath = dirname(browser.currentPath);
234
+ setBrowser((prev) => ({
235
+ ...prev,
236
+ inputBuffer: "",
237
+ currentPath: parentPath,
238
+ entries: readDirectory(parentPath),
239
+ completions: [],
240
+ scrollOffset: 0,
241
+ selectedFolderIndex: 0,
242
+ error: null,
243
+ }));
244
+ return;
245
+ }
246
+
247
+ // If there's other input beyond ~, clear it (back to ~)
248
+ if (browser.inputBuffer.length > 1) {
249
+ setBrowser((prev) => ({
250
+ ...prev,
251
+ inputBuffer: "~",
252
+ completions: [],
253
+ selectedFolderIndex: 0,
254
+ scrollOffset: 0,
255
+ error: null,
256
+ }));
257
+ return;
258
+ }
259
+
260
+ // Otherwise cancel
261
+ onCancel();
262
+ return;
263
+ }
264
+
265
+ // Tab: cycle completions and update current path
266
+ if (key.tab && browser.completions.length > 0) {
267
+ const nextIndex = (browser.completionIndex + 1) % browser.completions.length;
268
+ const completedPath = browser.completions[nextIndex]!;
269
+
270
+ // Check if it's a directory and add trailing slash
271
+ const expanded = completedPath.replace(/^~/, homedir());
272
+ const isDirectory = existsSync(expanded) && statSync(expanded).isDirectory();
273
+
274
+ // Use ~ format for home directory paths in input (for backspace-ability)
275
+ const home = homedir();
276
+ const inputPath = completedPath.startsWith(home)
277
+ ? "~" + completedPath.slice(home.length)
278
+ : completedPath;
279
+ const inputWithSlash = isDirectory ? inputPath + "/" : inputPath;
280
+
281
+ setBrowser((prev) => ({
282
+ ...prev,
283
+ completionIndex: nextIndex,
284
+ inputBuffer: inputWithSlash,
285
+ currentPath: completedPath,
286
+ entries: readDirectory(completedPath),
287
+ scrollOffset: 0,
288
+ selectedFolderIndex: 0,
289
+ error: null,
290
+ }));
291
+ return;
292
+ }
293
+
294
+ // Compute filtered entries for navigation (only filter if not a path)
295
+ const isPathInput = browser.inputBuffer.includes("/");
296
+ const filteredEntries = (browser.inputBuffer.length > 0 && !isPathInput)
297
+ ? browser.entries.filter(e =>
298
+ e.name.toLowerCase().includes(browser.inputBuffer.toLowerCase())
299
+ )
300
+ : browser.entries;
301
+
302
+ // j/k or arrow keys for navigating filtered folder list
303
+ if (input === "j" || key.downArrow) {
304
+ const maxIndex = Math.max(filteredEntries.length - 1, 0);
305
+ setBrowser((prev) => ({
306
+ ...prev,
307
+ selectedFolderIndex: Math.min(prev.selectedFolderIndex + 1, maxIndex),
308
+ // Auto-scroll if selection goes below visible area
309
+ scrollOffset: Math.max(prev.scrollOffset,
310
+ Math.min(prev.selectedFolderIndex + 1 - MAX_DISPLAY_FOLDERS + 1,
311
+ Math.max(filteredEntries.length - MAX_DISPLAY_FOLDERS, 0))),
312
+ }));
313
+ return;
314
+ }
315
+
316
+ if (input === "k" || key.upArrow) {
317
+ setBrowser((prev) => ({
318
+ ...prev,
319
+ selectedFolderIndex: Math.max(prev.selectedFolderIndex - 1, 0),
320
+ // Auto-scroll if selection goes above visible area
321
+ scrollOffset: Math.min(prev.scrollOffset, prev.selectedFolderIndex - 1),
322
+ }));
323
+ return;
324
+ }
325
+
326
+ // Space or Enter: select folder from filtered list or navigate current path
327
+ if (input === " " || key.return) {
328
+ // If there's a selected folder in filtered list, navigate into it
329
+ if (filteredEntries.length > 0 && browser.selectedFolderIndex < filteredEntries.length) {
330
+ const selectedEntry = filteredEntries[browser.selectedFolderIndex]!;
331
+ // Use ~ format for home directory paths in input
332
+ const home = homedir();
333
+ const inputPath = selectedEntry.fullPath.startsWith(home)
334
+ ? "~" + selectedEntry.fullPath.slice(home.length)
335
+ : selectedEntry.fullPath;
336
+ setBrowser((prev) => ({
337
+ ...prev,
338
+ inputBuffer: inputPath + "/",
339
+ currentPath: selectedEntry.fullPath,
340
+ entries: readDirectory(selectedEntry.fullPath),
341
+ completions: [],
342
+ scrollOffset: 0,
343
+ selectedFolderIndex: 0,
344
+ error: null,
345
+ }));
346
+ return;
347
+ }
348
+
349
+ // Otherwise, accept current path
350
+ if (key.return) {
351
+ const targetPath = browser.inputBuffer.replace(/\/$/, "") || browser.currentPath;
352
+ const validation = validateDirectoryPath(targetPath);
353
+ if (validation.valid) {
354
+ onSelect(targetPath);
355
+ } else {
356
+ setBrowser((prev) => ({
357
+ ...prev,
358
+ error: validation.error || "Invalid path",
359
+ }));
360
+ }
361
+ return;
362
+ }
363
+
364
+ // Space on current path with no selection
365
+ onSelect(browser.currentPath);
366
+ return;
367
+ }
368
+
369
+ // Ctrl+U: clear input back to ~
370
+ if (key.ctrl && input === 'u') {
371
+ setBrowser((prev) => ({
372
+ ...prev,
373
+ inputBuffer: "~",
374
+ completions: [],
375
+ selectedFolderIndex: 0,
376
+ scrollOffset: 0,
377
+ error: null,
378
+ }));
379
+ return;
380
+ }
381
+
382
+ // Backspace/delete
383
+ if ((key as any).backspace || (key as any).delete) {
384
+ if (browser.inputBuffer.length > 0) {
385
+ setBrowser((prev) => ({
386
+ ...prev,
387
+ inputBuffer: prev.inputBuffer.slice(0, -1),
388
+ selectedFolderIndex: 0,
389
+ scrollOffset: 0,
390
+ error: null,
391
+ }));
392
+ }
393
+ return;
394
+ }
395
+
396
+ // Regular character input
397
+ if (input.length === 1 && /[a-zA-Z0-9_\/\.~-]/.test(input)) {
398
+ setBrowser((prev) => ({
399
+ ...prev,
400
+ inputBuffer: prev.inputBuffer + input,
401
+ selectedFolderIndex: 0,
402
+ scrollOffset: 0,
403
+ error: null,
404
+ }));
405
+ return;
406
+ }
407
+ });
408
+
409
+ return (
410
+ <Box flexDirection="column" flexGrow={1}>
411
+ {/* Text input */}
412
+ <Box marginBottom={1}>
413
+ <Box gap={1}>
414
+ <Text color="yellow">{"> "}</Text>
415
+ <Text color="yellow">
416
+ {browser.inputBuffer}
417
+ </Text>
418
+ <Text color="yellow" inverse>_</Text>
419
+ </Box>
420
+ </Box>
421
+
422
+ {/* Error message */}
423
+ {browser.error && (
424
+ <Box marginBottom={1}>
425
+ <Text color="red">{browser.error}</Text>
426
+ </Box>
427
+ )}
428
+
429
+ {/* Directory list - filtered and scrollable */}
430
+ <Box flexDirection="column" marginBottom={1}>
431
+ <Text dimColor>Folders in {formatDisplayPath(browser.currentPath)}:</Text>
432
+ {(() => {
433
+ // Only filter by name if input doesn't contain slashes (not a path)
434
+ const isPathInput = browser.inputBuffer.includes("/");
435
+ const filteredEntries = (browser.inputBuffer.length > 0 && !isPathInput)
436
+ ? browser.entries.filter(e =>
437
+ e.name.toLowerCase().includes(browser.inputBuffer.toLowerCase())
438
+ )
439
+ : browser.entries;
440
+
441
+ if (filteredEntries.length === 0) {
442
+ return <Text dimColor>No matching folders</Text>;
443
+ }
444
+
445
+ const visibleEntries = filteredEntries.slice(
446
+ browser.scrollOffset,
447
+ browser.scrollOffset + MAX_DISPLAY_FOLDERS
448
+ );
449
+
450
+ return (
451
+ <>
452
+ {browser.scrollOffset > 0 && (
453
+ <Text dimColor>▲ {browser.scrollOffset} more above</Text>
454
+ )}
455
+ {visibleEntries.map((item, idx) => {
456
+ const globalIndex = browser.scrollOffset + idx;
457
+ const isSelected = globalIndex === browser.selectedFolderIndex;
458
+ return (
459
+ <Text key={item.fullPath}>
460
+ {isSelected ? <Text color="cyan">●</Text> : <Text dimColor>○</Text>}
461
+ {" "}{item.name}/
462
+ </Text>
463
+ );
464
+ })}
465
+ {browser.scrollOffset + MAX_DISPLAY_FOLDERS < filteredEntries.length && (
466
+ <Text dimColor>▼ {filteredEntries.length - browser.scrollOffset - MAX_DISPLAY_FOLDERS} more below</Text>
467
+ )}
468
+ </>
469
+ );
470
+ })()}
471
+ </Box>
472
+
473
+ {/* Help text */}
474
+ <Box flexDirection="column" marginTop={1}>
475
+ <Box gap={2}>
476
+ <Text dimColor>Tab:</Text>
477
+ <Text dimColor>complete</Text>
478
+ <Text dimColor>j/k:</Text>
479
+ <Text dimColor>navigate</Text>
480
+ <Text dimColor>Space:</Text>
481
+ <Text dimColor>navigate folder</Text>
482
+ </Box>
483
+ <Box gap={2}>
484
+ <Text dimColor>Enter:</Text>
485
+ <Text dimColor>select</Text>
486
+ <Text dimColor>Esc:</Text>
487
+ <Text dimColor>go back</Text>
488
+ </Box>
489
+ </Box>
490
+ </Box>
491
+ );
492
+ }
493
+
494
+ export function DirectoriesStep({
495
+ directories,
496
+ onComplete,
497
+ onBack,
498
+ onCancel,
499
+ }: DirectoriesStepProps) {
500
+ const [items, setItems] = useState<DirectoryItem[]>(
501
+ directories.map((d) => ({
502
+ ...d,
503
+ valid: true,
504
+ }))
505
+ );
506
+ const [selectedIndex, setSelectedIndex] = useState(0);
507
+ const [mode, setMode] = useState<"list" | "browse" | "maxDepth" | "label">("list");
508
+ const [input, setInput] = useState<InputState>({
509
+ maxDepth: "2",
510
+ label: "",
511
+ showLabelInput: false,
512
+ });
513
+ const [selectedPath, setSelectedPath] = useState<string | null>(null);
514
+ const [error, setError] = useState<string | null>(null);
515
+
516
+ useInput((inputStr, key) => {
517
+ if (mode === "browse") {
518
+ // Handled by HybridDirectoryBrowser
519
+ return;
520
+ }
521
+
522
+ if (mode === "maxDepth" || mode === "label") {
523
+ handleDepthOrLabelInput(inputStr, key);
524
+ return;
525
+ }
526
+
527
+ // List mode
528
+ if (key.escape || inputStr === "q" || inputStr === "Q") {
529
+ onCancel();
530
+ return;
531
+ }
532
+
533
+ if ((key as any).backspace || (key as any).delete) {
534
+ onBack();
535
+ return;
536
+ }
537
+
538
+ if (key.return) {
539
+ if (items.length > 0) {
540
+ handleComplete();
541
+ }
542
+ return;
543
+ }
544
+
545
+ // Navigate items
546
+ if (inputStr === "j" || key.downArrow) {
547
+ setSelectedIndex((i) => Math.min(i + 1, Math.max(items.length - 1, 0)));
548
+ return;
549
+ }
550
+
551
+ if (inputStr === "k" || key.upArrow) {
552
+ setSelectedIndex((i) => Math.max(i - 1, 0));
553
+ return;
554
+ }
555
+
556
+ // Add directory
557
+ if (inputStr === "a" || inputStr === "A") {
558
+ setMode("browse");
559
+ setError(null);
560
+ return;
561
+ }
562
+
563
+ // Delete selected
564
+ if ((inputStr === "d" || inputStr === "D") && items.length > 0) {
565
+ setItems((prev) => prev.filter((_, i) => i !== selectedIndex));
566
+ if (selectedIndex >= items.length - 1) {
567
+ setSelectedIndex(Math.max(items.length - 2, 0));
568
+ }
569
+ return;
570
+ }
571
+
572
+ // Skip with defaults
573
+ if (inputStr === "s" || inputStr === "S") {
574
+ handleSkip();
575
+ return;
576
+ }
577
+
578
+ // Edit selected
579
+ if ((inputStr === "e" || inputStr === "E") && items.length > 0) {
580
+ const item = items[selectedIndex];
581
+ if (item) {
582
+ setSelectedPath(item.path);
583
+ setMode("maxDepth");
584
+ setInput({
585
+ maxDepth: String(item.maxDepth),
586
+ label: item.label || "",
587
+ showLabelInput: !!item.label,
588
+ });
589
+ setError(null);
590
+ }
591
+ return;
592
+ }
593
+ });
594
+
595
+ const handleDepthOrLabelInput = (inputStr: string, key: { return: boolean; escape: boolean; tab: boolean }) => {
596
+ if (key.escape) {
597
+ // In maxDepth mode, go back to browser
598
+ if (mode === "maxDepth") {
599
+ setMode("browse");
600
+ setError(null);
601
+ return;
602
+ }
603
+ // In label mode, go back to list
604
+ setMode("list");
605
+ setSelectedPath(null);
606
+ setInput({ maxDepth: "2", label: "", showLabelInput: false });
607
+ setError(null);
608
+ return;
609
+ }
610
+
611
+ if (key.return) {
612
+ if (mode === "maxDepth") {
613
+ const depth = parseInt(input.maxDepth, 10);
614
+ if (isNaN(depth) || depth < 0 || depth > 10) {
615
+ setError("Max depth must be between 0 and 10");
616
+ return;
617
+ }
618
+ setMode("label");
619
+ setError(null);
620
+ } else if (mode === "label") {
621
+ // Add or update the directory
622
+ if (!selectedPath) return;
623
+
624
+ const newItem: DirectoryItem = {
625
+ path: selectedPath,
626
+ maxDepth: parseInt(input.maxDepth, 10),
627
+ label: input.label || undefined,
628
+ valid: true,
629
+ };
630
+
631
+ setItems((prev) => {
632
+ const existingIndex = prev.findIndex((i) => i.path === selectedPath);
633
+ if (existingIndex >= 0) {
634
+ const updated = [...prev];
635
+ updated[existingIndex] = newItem;
636
+ return updated;
637
+ }
638
+ return [...prev, newItem];
639
+ });
640
+
641
+ setMode("list");
642
+ setSelectedPath(null);
643
+ setInput({ maxDepth: "2", label: "", showLabelInput: false });
644
+ setError(null);
645
+ }
646
+ return;
647
+ }
648
+
649
+ if (key.tab && mode === "maxDepth") {
650
+ setInput((prev) => ({ ...prev, showLabelInput: !prev.showLabelInput }));
651
+ return;
652
+ }
653
+
654
+ if (mode === "maxDepth") {
655
+ if ((key as any).backspace || (key as any).delete) {
656
+ setInput((prev) => ({ ...prev, maxDepth: prev.maxDepth.slice(0, -1) }));
657
+ } else if (/^[0-9]$/.test(inputStr)) {
658
+ setInput((prev) => ({ ...prev, maxDepth: prev.maxDepth + inputStr }));
659
+ }
660
+ } else if (mode === "label") {
661
+ if ((key as any).backspace || (key as any).delete) {
662
+ setInput((prev) => ({ ...prev, label: prev.label.slice(0, -1) }));
663
+ } else {
664
+ setInput((prev) => ({ ...prev, label: prev.label + inputStr }));
665
+ }
666
+ }
667
+ };
668
+
669
+ const handleBrowserSelect = (path: string) => {
670
+ setSelectedPath(path);
671
+ setMode("maxDepth");
672
+ setInput({ maxDepth: "2", label: "", showLabelInput: false });
673
+ };
674
+
675
+ const handleBrowserCancel = () => {
676
+ setMode("list");
677
+ setError(null);
678
+ };
679
+
680
+ const handleSkip = () => {
681
+ const defaultDir: DirectoryItem = {
682
+ path: `${homedir()}/projects`,
683
+ maxDepth: 2,
684
+ label: "Projects",
685
+ valid: true,
686
+ };
687
+ onComplete([{ path: defaultDir.path, maxDepth: defaultDir.maxDepth, label: defaultDir.label }]);
688
+ };
689
+
690
+ const handleComplete = () => {
691
+ const validItems = items.filter((i) => i.valid);
692
+ if (validItems.length === 0) {
693
+ setError("Add at least one directory");
694
+ return;
695
+ }
696
+ onComplete(
697
+ validItems.map((i) => ({
698
+ path: i.path,
699
+ maxDepth: i.maxDepth,
700
+ label: i.label,
701
+ }))
702
+ );
703
+ };
704
+
705
+ return (
706
+ <Box
707
+ flexDirection="column"
708
+ borderStyle="round"
709
+ borderColor="cyan"
710
+ paddingX={2}
711
+ paddingY={1}
712
+ width={80}
713
+ >
714
+ {/* Title */}
715
+ <Box marginBottom={1}>
716
+ <Text bold color="cyan">
717
+ Configure Project Directories
718
+ </Text>
719
+ </Box>
720
+
721
+ {/* Instructions */}
722
+ {mode === "list" && (
723
+ <Box marginBottom={1}>
724
+ <Text>Add the directories containing your git projects.</Text>
725
+ </Box>
726
+ )}
727
+
728
+ {/* Browser mode */}
729
+ {mode === "browse" && (
730
+ <HybridDirectoryBrowser
731
+ startingPath={homedir()}
732
+ onSelect={handleBrowserSelect}
733
+ onCancel={handleBrowserCancel}
734
+ />
735
+ )}
736
+
737
+ {/* MaxDepth input */}
738
+ {mode === "maxDepth" && (
739
+ <Box flexDirection="column" marginBottom={1}>
740
+ <Text>Max scan depth (0-10, default 2):</Text>
741
+ <Box gap={1}>
742
+ <Text color="yellow">{"> "}</Text>
743
+ <Text color="yellow">{input.maxDepth}</Text>
744
+ <Text color="yellow" inverse>_</Text>
745
+ </Box>
746
+ <Text dimColor>Path: {formatDisplayPath(selectedPath || "")}</Text>
747
+ <Text dimColor>Press Enter to confirm, Esc to cancel</Text>
748
+ </Box>
749
+ )}
750
+
751
+ {/* Label input */}
752
+ {mode === "label" && (
753
+ <Box flexDirection="column" marginBottom={1}>
754
+ <Text>Label (optional, press Enter to skip):</Text>
755
+ <Box gap={1}>
756
+ <Text color="yellow">{"> "}</Text>
757
+ <Text color="yellow">{input.label}</Text>
758
+ <Text color="yellow" inverse>_</Text>
759
+ </Box>
760
+ <Text dimColor>Path: {formatDisplayPath(selectedPath || "")}</Text>
761
+ <Text dimColor>Press Enter to confirm, Esc to cancel</Text>
762
+ </Box>
763
+ )}
764
+
765
+ {/* List mode */}
766
+ {mode === "list" && (
767
+ <>
768
+ {/* Current directories */}
769
+ {items.length > 0 && (
770
+ <Box flexDirection="column" marginBottom={1}>
771
+ <Text dimColor>Current directories:</Text>
772
+ {items.map((item, index) => (
773
+ <Box key={item.path} gap={2}>
774
+ <Text color={index === selectedIndex ? "cyan" : "gray"}>
775
+ {index === selectedIndex ? "●" : "○"}
776
+ </Text>
777
+ <Text color={index === selectedIndex ? "white" : "gray"}>
778
+ {item.label || item.path}
779
+ </Text>
780
+ <Text dimColor>
781
+ (depth: {item.maxDepth})
782
+ </Text>
783
+ </Box>
784
+ ))}
785
+ </Box>
786
+ )}
787
+
788
+ {/* Empty state */}
789
+ {items.length === 0 && (
790
+ <Box flexDirection="column" marginBottom={1}>
791
+ <Text dimColor>No directories added yet.</Text>
792
+ <Text dimColor>Press 'a' to browse your filesystem.</Text>
793
+ </Box>
794
+ )}
795
+
796
+ {/* Error message */}
797
+ {error && (
798
+ <Box marginBottom={1}>
799
+ <Text color="red">{error}</Text>
800
+ </Box>
801
+ )}
802
+
803
+ {/* Actions */}
804
+ <Box gap={2} marginTop={1}>
805
+ <Text dimColor>Shortcuts:</Text>
806
+ <Text color="green" bold>
807
+ a
808
+ </Text>
809
+ <Text dimColor>Add</Text>
810
+ {items.length > 0 && (
811
+ <>
812
+ <Text color="green" bold>
813
+ d
814
+ </Text>
815
+ <Text dimColor>Delete</Text>
816
+ <Text color="green" bold>
817
+ e
818
+ </Text>
819
+ <Text dimColor>Edit</Text>
820
+ </>
821
+ )}
822
+ <Text color="green" bold>
823
+ s
824
+ </Text>
825
+ <Text dimColor>Skip (defaults)</Text>
826
+ </Box>
827
+ <Box gap={2}>
828
+ <Text dimColor>j/k</Text>
829
+ <Text dimColor>Navigate</Text>
830
+ <Text color="green" bold>
831
+ Enter
832
+ </Text>
833
+ <Text dimColor>Continue</Text>
834
+ <Text color="red" bold>
835
+ Backspace
836
+ </Text>
837
+ <Text dimColor>Back</Text>
838
+ <Text color="red" bold>
839
+ q
840
+ </Text>
841
+ <Text dimColor>Quit</Text>
842
+ </Box>
843
+ </>
844
+ )}
845
+ </Box>
846
+ );
847
+ }