auq-mcp-server 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 (66) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +176 -0
  3. package/dist/__tests__/schema-validation.test.js +137 -0
  4. package/dist/__tests__/server.integration.test.js +263 -0
  5. package/dist/add.js +1 -0
  6. package/dist/add.test.js +5 -0
  7. package/dist/bin/auq.js +245 -0
  8. package/dist/bin/test-session-menu.js +28 -0
  9. package/dist/bin/test-tabbar.js +42 -0
  10. package/dist/file-utils.js +59 -0
  11. package/dist/format/ResponseFormatter.js +206 -0
  12. package/dist/format/__tests__/ResponseFormatter.test.js +380 -0
  13. package/dist/package.json +74 -0
  14. package/dist/server.js +107 -0
  15. package/dist/session/ResponseFormatter.js +130 -0
  16. package/dist/session/SessionManager.js +474 -0
  17. package/dist/session/__tests__/ResponseFormatter.test.js +417 -0
  18. package/dist/session/__tests__/SessionManager.test.js +553 -0
  19. package/dist/session/__tests__/atomic-operations.test.js +345 -0
  20. package/dist/session/__tests__/file-watcher.test.js +311 -0
  21. package/dist/session/__tests__/workflow.integration.test.js +334 -0
  22. package/dist/session/atomic-operations.js +307 -0
  23. package/dist/session/file-watcher.js +218 -0
  24. package/dist/session/index.js +7 -0
  25. package/dist/session/types.js +20 -0
  26. package/dist/session/utils.js +125 -0
  27. package/dist/session-manager.js +171 -0
  28. package/dist/session-watcher.js +110 -0
  29. package/dist/src/__tests__/schema-validation.test.js +170 -0
  30. package/dist/src/__tests__/server.integration.test.js +274 -0
  31. package/dist/src/add.js +1 -0
  32. package/dist/src/add.test.js +5 -0
  33. package/dist/src/server.js +163 -0
  34. package/dist/src/session/ResponseFormatter.js +163 -0
  35. package/dist/src/session/SessionManager.js +572 -0
  36. package/dist/src/session/__tests__/ResponseFormatter.test.js +741 -0
  37. package/dist/src/session/__tests__/SessionManager.test.js +593 -0
  38. package/dist/src/session/__tests__/atomic-operations.test.js +346 -0
  39. package/dist/src/session/__tests__/file-watcher.test.js +311 -0
  40. package/dist/src/session/atomic-operations.js +307 -0
  41. package/dist/src/session/file-watcher.js +227 -0
  42. package/dist/src/session/index.js +7 -0
  43. package/dist/src/session/types.js +20 -0
  44. package/dist/src/session/utils.js +180 -0
  45. package/dist/src/tui/__tests__/session-watcher.test.js +368 -0
  46. package/dist/src/tui/components/AnimatedGradient.js +45 -0
  47. package/dist/src/tui/components/ConfirmationDialog.js +89 -0
  48. package/dist/src/tui/components/CustomInput.js +14 -0
  49. package/dist/src/tui/components/Footer.js +55 -0
  50. package/dist/src/tui/components/Header.js +35 -0
  51. package/dist/src/tui/components/MultiLineTextInput.js +65 -0
  52. package/dist/src/tui/components/OptionsList.js +115 -0
  53. package/dist/src/tui/components/QuestionDisplay.js +36 -0
  54. package/dist/src/tui/components/ReviewScreen.js +57 -0
  55. package/dist/src/tui/components/SessionSelectionMenu.js +151 -0
  56. package/dist/src/tui/components/StepperView.js +166 -0
  57. package/dist/src/tui/components/TabBar.js +42 -0
  58. package/dist/src/tui/components/Toast.js +19 -0
  59. package/dist/src/tui/components/WaitingScreen.js +20 -0
  60. package/dist/src/tui/session-watcher.js +195 -0
  61. package/dist/src/tui/theme.js +114 -0
  62. package/dist/src/tui/utils/gradientText.js +24 -0
  63. package/dist/tui/__tests__/session-watcher.test.js +368 -0
  64. package/dist/tui/session-watcher.js +183 -0
  65. package/package.json +78 -0
  66. package/scripts/postinstall.cjs +51 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Utility functions for session management
3
+ */
4
+ import { constants, existsSync } from "fs";
5
+ import { promises as fs } from "fs";
6
+ import { homedir } from "os";
7
+ import { join } from "path";
8
+ import { fileURLToPath } from "url";
9
+ /**
10
+ * Create a safe filename from a session ID (basic validation)
11
+ */
12
+ export function createSafeFilename(sessionId, filename) {
13
+ if (!sanitizeSessionId(sessionId)) {
14
+ throw new Error(`Invalid session ID format: ${sessionId}`);
15
+ }
16
+ return filename;
17
+ }
18
+ /**
19
+ * Ensure a directory exists with proper permissions
20
+ */
21
+ export async function ensureDirectoryExists(dirPath) {
22
+ try {
23
+ await fs.access(dirPath, constants.W_OK);
24
+ }
25
+ catch {
26
+ await fs.mkdir(dirPath, { mode: 0o700, recursive: true });
27
+ }
28
+ }
29
+ /**
30
+ * Check if a file exists and is readable
31
+ */
32
+ export async function fileExists(filePath) {
33
+ try {
34
+ await fs.access(filePath, constants.R_OK);
35
+ return true;
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ /**
42
+ * Get the current timestamp in ISO format
43
+ */
44
+ export function getCurrentTimestamp() {
45
+ return new Date().toISOString();
46
+ }
47
+ /**
48
+ * Check if a timestamp is older than the specified timeout in milliseconds
49
+ */
50
+ export function isTimestampExpired(timestamp, timeoutMs) {
51
+ const now = new Date().getTime();
52
+ const timestampTime = new Date(timestamp).getTime();
53
+ return now - timestampTime > timeoutMs;
54
+ }
55
+ /**
56
+ * Resolve session directory path using XDG Base Directory specification
57
+ * Falls back to user home directory if XDG is not available
58
+ */
59
+ export function resolveSessionDirectory(baseDir) {
60
+ if (baseDir) {
61
+ // If baseDir is provided, expand any ~ to home directory
62
+ if (baseDir.startsWith("~")) {
63
+ return join(homedir(), baseDir.slice(1));
64
+ }
65
+ return baseDir;
66
+ }
67
+ // Default XDG-compliant paths
68
+ const home = homedir();
69
+ const platform = process.platform;
70
+ if (platform === "darwin") {
71
+ // macOS: ~/Library/Application Support/
72
+ return join(home, "Library", "Application Support", "auq", "sessions");
73
+ }
74
+ else if (platform === "win32") {
75
+ // Windows: %APPDATA%/auq/sessions/
76
+ const appData = process.env.APPDATA;
77
+ if (appData) {
78
+ return join(appData, "auq", "sessions");
79
+ }
80
+ // Fallback to user profile
81
+ const userProfile = process.env.USERPROFILE || home;
82
+ return join(userProfile, "auq", "sessions");
83
+ }
84
+ else {
85
+ // Linux/Unix: ~/.local/share/ (XDG Base Directory)
86
+ // Check for XDG_DATA_HOME environment variable
87
+ const xdgDataHome = process.env.XDG_DATA_HOME;
88
+ if (xdgDataHome) {
89
+ return join(xdgDataHome, "auq", "sessions");
90
+ }
91
+ // Fallback to ~/.local/share/
92
+ return join(home, ".local", "share", "auq", "sessions");
93
+ }
94
+ }
95
+ /**
96
+ * Safely parse JSON with error handling
97
+ */
98
+ export function safeJsonParse(json, fallback) {
99
+ try {
100
+ return JSON.parse(json);
101
+ }
102
+ catch {
103
+ return fallback;
104
+ }
105
+ }
106
+ /**
107
+ * Validate that a session ID follows UUID v4 format
108
+ */
109
+ export function sanitizeSessionId(sessionId) {
110
+ // Basic validation - UUID v4 format
111
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
112
+ return uuidRegex.test(sessionId);
113
+ }
114
+ /**
115
+ * Validate that a session directory exists and is accessible
116
+ */
117
+ export async function validateSessionDirectory(baseDir) {
118
+ try {
119
+ await fs.access(baseDir, constants.R_OK | constants.W_OK);
120
+ const stat = await fs.stat(baseDir);
121
+ return stat.isDirectory();
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ }
127
+ /**
128
+ * Detect whether AUQ is installed globally or locally
129
+ * by inspecting the module's location
130
+ */
131
+ export function detectInstallMode() {
132
+ try {
133
+ // Get current module's file path
134
+ const __filename = fileURLToPath(import.meta.url);
135
+ const parts = __filename.split(/[/\\]/); // Handle both Unix and Windows separators
136
+ // Find the last occurrence of 'node_modules' in the path
137
+ const nodeModulesIndex = parts.lastIndexOf("node_modules");
138
+ if (nodeModulesIndex === -1) {
139
+ // Not in node_modules (development or global install without node_modules in path)
140
+ return { mode: "global" };
141
+ }
142
+ // We're inside node_modules - check if there's a project package.json above
143
+ const potentialProjectRoot = parts.slice(0, nodeModulesIndex).join("/");
144
+ const packageJsonPath = join(potentialProjectRoot, "package.json");
145
+ if (existsSync(packageJsonPath)) {
146
+ // Found a package.json above node_modules → local install
147
+ return { mode: "local", projectRoot: potentialProjectRoot };
148
+ }
149
+ // In node_modules but no project context → global install
150
+ return { mode: "global" };
151
+ }
152
+ catch (error) {
153
+ // Fallback to global mode if detection fails
154
+ console.error("[AUQ] Failed to detect install mode:", error);
155
+ return { mode: "global" };
156
+ }
157
+ }
158
+ /**
159
+ * Get the appropriate session directory based on installation mode
160
+ * Supports environment variable override via AUQ_SESSION_DIR
161
+ */
162
+ export function getSessionDirectory() {
163
+ // Check for environment variable override first
164
+ if (process.env.AUQ_SESSION_DIR) {
165
+ const envDir = process.env.AUQ_SESSION_DIR;
166
+ // Expand ~ to home directory if needed
167
+ if (envDir.startsWith("~")) {
168
+ return join(homedir(), envDir.slice(1));
169
+ }
170
+ return envDir;
171
+ }
172
+ // Auto-detect installation mode
173
+ const { mode, projectRoot } = detectInstallMode();
174
+ if (mode === "local" && projectRoot) {
175
+ // Local install: use project-relative .auq/sessions directory
176
+ return join(projectRoot, ".auq", "sessions");
177
+ }
178
+ // Global install: use XDG-compliant system paths
179
+ return resolveSessionDirectory();
180
+ }
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Unit tests for TUI session watcher functionality
3
+ */
4
+ import { promises as fs } from "fs";
5
+ import { join } from "path";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { SESSION_FILES } from "../../session/types.js";
8
+ import { createTUIWatcher, EnhancedTUISessionWatcher, getNextPendingSession, } from "../session-watcher.js";
9
+ describe("TUI Session Watcher", () => {
10
+ const testDir = "/tmp/auq-tui-watcher-test";
11
+ const sessionDir = join(testDir, "sessions");
12
+ const testSessionId = "test-session-123";
13
+ const mockSessionRequest = {
14
+ questions: [
15
+ {
16
+ options: [
17
+ { description: "Dynamic web language", label: "JavaScript" },
18
+ { description: "Typed JavaScript", label: "TypeScript" },
19
+ { description: "Versatile and readable", label: "Python" },
20
+ ],
21
+ prompt: "What is your favorite programming language?",
22
+ title: "Language",
23
+ },
24
+ ],
25
+ sessionId: testSessionId,
26
+ status: "pending",
27
+ timestamp: new Date().toISOString(),
28
+ };
29
+ beforeEach(async () => {
30
+ // Clean up test directory before each test
31
+ await fs.rm(testDir, { force: true, recursive: true }).catch(() => { });
32
+ await fs.mkdir(sessionDir, { recursive: true });
33
+ });
34
+ afterEach(async () => {
35
+ // Clean up test directory after each test
36
+ await fs.rm(testDir, { force: true, recursive: true }).catch(() => { });
37
+ });
38
+ describe("EnhancedTUISessionWatcher", () => {
39
+ describe("session event detection", () => {
40
+ it("should detect new sessions with loaded data", async () => {
41
+ const watcher = new EnhancedTUISessionWatcher({
42
+ autoLoadData: true,
43
+ debounceMs: 100,
44
+ sessionDir,
45
+ });
46
+ const events = [];
47
+ watcher.startEnhancedWatching((event) => {
48
+ events.push(event);
49
+ });
50
+ // Give watcher time to initialize
51
+ await new Promise((resolve) => setTimeout(resolve, 50));
52
+ // Create a new session directory
53
+ const newSessionDir = join(sessionDir, testSessionId);
54
+ await fs.mkdir(newSessionDir);
55
+ // Create required session files
56
+ const requestFile = join(newSessionDir, SESSION_FILES.REQUEST);
57
+ const statusFile = join(newSessionDir, SESSION_FILES.STATUS);
58
+ await Promise.all([
59
+ fs.writeFile(requestFile, JSON.stringify(mockSessionRequest, null, 2)),
60
+ fs.writeFile(statusFile, JSON.stringify({
61
+ createdAt: new Date().toISOString(),
62
+ lastModified: new Date().toISOString(),
63
+ sessionId: testSessionId,
64
+ status: "pending",
65
+ totalQuestions: 1,
66
+ })),
67
+ ]);
68
+ // Wait for debounce (100ms) + processing time
69
+ await new Promise((resolve) => setTimeout(resolve, 300));
70
+ expect(events).toHaveLength(1);
71
+ expect(events[0].type).toBe("session-created");
72
+ expect(events[0].sessionId).toBe(testSessionId);
73
+ expect(events[0].sessionRequest).toEqual(mockSessionRequest);
74
+ watcher.stop();
75
+ });
76
+ it("should handle autoLoadData disabled", async () => {
77
+ const watcher = new EnhancedTUISessionWatcher({
78
+ autoLoadData: false,
79
+ debounceMs: 100,
80
+ sessionDir,
81
+ });
82
+ const events = [];
83
+ watcher.startEnhancedWatching((event) => {
84
+ events.push(event);
85
+ });
86
+ // Give watcher time to initialize
87
+ await new Promise((resolve) => setTimeout(resolve, 50));
88
+ // Create session directory and files
89
+ const newSessionDir = join(sessionDir, testSessionId);
90
+ await fs.mkdir(newSessionDir);
91
+ const requestFile = join(newSessionDir, SESSION_FILES.REQUEST);
92
+ await fs.writeFile(requestFile, JSON.stringify(mockSessionRequest, null, 2));
93
+ // Wait for debounce + processing
94
+ await new Promise((resolve) => setTimeout(resolve, 300));
95
+ expect(events).toHaveLength(1);
96
+ expect(events[0].type).toBe("session-created");
97
+ expect(events[0].sessionId).toBe(testSessionId);
98
+ expect(events[0].sessionRequest).toBeUndefined(); // Should not be loaded
99
+ watcher.stop();
100
+ });
101
+ it("should handle corrupted session files gracefully", async () => {
102
+ const watcher = new EnhancedTUISessionWatcher({
103
+ autoLoadData: true,
104
+ debounceMs: 100,
105
+ sessionDir,
106
+ });
107
+ const events = [];
108
+ const consoleSpy = vi
109
+ .spyOn(console, "warn")
110
+ .mockImplementation(() => { });
111
+ watcher.startEnhancedWatching((event) => {
112
+ events.push(event);
113
+ });
114
+ // Create session with corrupted request.json
115
+ const newSessionDir = join(sessionDir, testSessionId);
116
+ await fs.mkdir(newSessionDir);
117
+ const requestFile = join(newSessionDir, SESSION_FILES.REQUEST);
118
+ const statusFile = join(newSessionDir, SESSION_FILES.STATUS);
119
+ // Write corrupted request.json and valid status.json
120
+ await Promise.all([
121
+ fs.writeFile(requestFile, "invalid json content"),
122
+ fs.writeFile(statusFile, JSON.stringify({
123
+ createdAt: new Date().toISOString(),
124
+ lastModified: new Date().toISOString(),
125
+ sessionId: testSessionId,
126
+ status: "pending",
127
+ totalQuestions: 1,
128
+ })),
129
+ ]);
130
+ // Wait for debounce + processing (increased for reliability)
131
+ await new Promise((resolve) => setTimeout(resolve, 500));
132
+ expect(events).toHaveLength(1);
133
+ expect(events[0].type).toBe("session-created");
134
+ expect(events[0].sessionId).toBe(testSessionId);
135
+ expect(events[0].sessionRequest).toBeUndefined();
136
+ // Should have logged a warning about corrupted data
137
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to load session request"), expect.any(Error));
138
+ consoleSpy.mockRestore();
139
+ watcher.stop();
140
+ });
141
+ });
142
+ describe("event handlers", () => {
143
+ it("should support multiple event handlers", async () => {
144
+ const watcher = new EnhancedTUISessionWatcher({
145
+ debounceMs: 100,
146
+ sessionDir,
147
+ });
148
+ const mainEvents = [];
149
+ const customEvents = [];
150
+ watcher.addEventHandler("custom", (event) => {
151
+ customEvents.push(event);
152
+ });
153
+ watcher.startEnhancedWatching((event) => {
154
+ mainEvents.push(event);
155
+ });
156
+ // Give watcher time to initialize
157
+ await new Promise((resolve) => setTimeout(resolve, 50));
158
+ // Create session
159
+ const newSessionDir = join(sessionDir, testSessionId);
160
+ await fs.mkdir(newSessionDir);
161
+ const requestFile = join(newSessionDir, SESSION_FILES.REQUEST);
162
+ await fs.writeFile(requestFile, JSON.stringify(mockSessionRequest, null, 2));
163
+ // Wait for debounce + processing
164
+ await new Promise((resolve) => setTimeout(resolve, 300));
165
+ expect(mainEvents).toHaveLength(1);
166
+ expect(customEvents).toHaveLength(1);
167
+ expect(customEvents[0]).toEqual(mainEvents[0]);
168
+ watcher.removeEventHandler("custom");
169
+ watcher.stop();
170
+ });
171
+ it("should handle event handler removal", async () => {
172
+ const watcher = new EnhancedTUISessionWatcher({ sessionDir });
173
+ const customEvents = [];
174
+ watcher.addEventHandler("custom", (event) => {
175
+ customEvents.push(event);
176
+ });
177
+ watcher.removeEventHandler("custom");
178
+ watcher.startEnhancedWatching(() => { });
179
+ // Create session
180
+ const newSessionDir = join(sessionDir, testSessionId);
181
+ await fs.mkdir(newSessionDir);
182
+ const requestFile = join(newSessionDir, SESSION_FILES.REQUEST);
183
+ await fs.writeFile(requestFile, JSON.stringify(mockSessionRequest, null, 2));
184
+ // Wait for processing
185
+ await new Promise((resolve) => setTimeout(resolve, 100));
186
+ expect(customEvents).toHaveLength(0); // Should not have triggered
187
+ watcher.stop();
188
+ });
189
+ });
190
+ describe("session management", () => {
191
+ beforeEach(async () => {
192
+ // Create multiple test sessions
193
+ const sessions = [
194
+ { completed: false, id: "session-1" },
195
+ { completed: true, id: "session-2" },
196
+ { completed: false, id: "session-3" },
197
+ ];
198
+ for (const session of sessions) {
199
+ const sessionDir = join(testDir, "sessions", session.id);
200
+ await fs.mkdir(sessionDir, { recursive: true });
201
+ // Create request.json
202
+ const requestFile = join(sessionDir, SESSION_FILES.REQUEST);
203
+ await fs.writeFile(requestFile, JSON.stringify({
204
+ ...mockSessionRequest,
205
+ sessionId: session.id,
206
+ }));
207
+ // Create status.json
208
+ const statusFile = join(sessionDir, SESSION_FILES.STATUS);
209
+ await fs.writeFile(statusFile, JSON.stringify({
210
+ createdAt: new Date().toISOString(),
211
+ lastModified: new Date().toISOString(),
212
+ sessionId: session.id,
213
+ status: session.completed ? "completed" : "pending",
214
+ totalQuestions: 1,
215
+ }));
216
+ // Create answers.json for completed sessions
217
+ if (session.completed) {
218
+ const answersFile = join(sessionDir, SESSION_FILES.ANSWERS);
219
+ await fs.writeFile(answersFile, JSON.stringify({
220
+ answers: [
221
+ {
222
+ questionIndex: 0,
223
+ selectedOption: "JavaScript",
224
+ timestamp: new Date().toISOString(),
225
+ },
226
+ ],
227
+ sessionId: session.id,
228
+ timestamp: new Date().toISOString(),
229
+ }));
230
+ }
231
+ }
232
+ });
233
+ it("should get pending sessions correctly", async () => {
234
+ const watcher = new EnhancedTUISessionWatcher({ sessionDir });
235
+ const pendingSessions = await watcher.getPendingSessions();
236
+ expect(pendingSessions).toHaveLength(2);
237
+ expect(pendingSessions).toContain("session-1");
238
+ expect(pendingSessions).toContain("session-3");
239
+ expect(pendingSessions).not.toContain("session-2");
240
+ // Should be sorted
241
+ expect(pendingSessions[0]).toBe("session-1");
242
+ expect(pendingSessions[1]).toBe("session-3");
243
+ watcher.stop();
244
+ });
245
+ it("should get session request data", async () => {
246
+ const watcher = new EnhancedTUISessionWatcher({ sessionDir });
247
+ const sessionRequest = await watcher.getSessionRequest("session-1");
248
+ expect(sessionRequest).toBeTruthy();
249
+ expect(sessionRequest?.sessionId).toBe("session-1");
250
+ expect(sessionRequest?.questions).toHaveLength(1);
251
+ watcher.stop();
252
+ });
253
+ it("should handle non-existent session request", async () => {
254
+ const watcher = new EnhancedTUISessionWatcher({ sessionDir });
255
+ const consoleSpy = vi
256
+ .spyOn(console, "warn")
257
+ .mockImplementation(() => { });
258
+ const sessionRequest = await watcher.getSessionRequest("non-existent");
259
+ expect(sessionRequest).toBeNull();
260
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to load session request"), expect.any(Error));
261
+ consoleSpy.mockRestore();
262
+ watcher.stop();
263
+ });
264
+ it("should check session pending status correctly", async () => {
265
+ const watcher = new EnhancedTUISessionWatcher({ sessionDir });
266
+ expect(await watcher.isSessionPending("session-1")).toBe(true);
267
+ expect(await watcher.isSessionPending("session-2")).toBe(false);
268
+ expect(await watcher.isSessionPending("session-3")).toBe(true);
269
+ expect(await watcher.isSessionPending("non-existent")).toBe(false);
270
+ watcher.stop();
271
+ });
272
+ });
273
+ });
274
+ describe("Utility Functions", () => {
275
+ describe("createTUIWatcher", () => {
276
+ it("should create an enhanced TUI session watcher", () => {
277
+ const watcher = createTUIWatcher({ sessionDir });
278
+ expect(watcher).toBeInstanceOf(EnhancedTUISessionWatcher);
279
+ expect(watcher.watchedPath).toBe(sessionDir);
280
+ watcher.stop();
281
+ });
282
+ });
283
+ describe("getNextPendingSession", () => {
284
+ beforeEach(async () => {
285
+ // Create test sessions
286
+ const sessions = [
287
+ { id: "first-session", pending: true },
288
+ { id: "second-session", pending: true },
289
+ ];
290
+ for (const session of sessions) {
291
+ const sessionDir = join(testDir, "sessions", session.id);
292
+ await fs.mkdir(sessionDir, { recursive: true });
293
+ // Create required files
294
+ const requestFile = join(sessionDir, SESSION_FILES.REQUEST);
295
+ const statusFile = join(sessionDir, SESSION_FILES.STATUS);
296
+ await Promise.all([
297
+ fs.writeFile(requestFile, JSON.stringify({
298
+ ...mockSessionRequest,
299
+ sessionId: session.id,
300
+ })),
301
+ fs.writeFile(statusFile, JSON.stringify({
302
+ createdAt: new Date().toISOString(),
303
+ lastModified: new Date().toISOString(),
304
+ sessionId: session.id,
305
+ status: "pending",
306
+ totalQuestions: 1,
307
+ })),
308
+ ]);
309
+ }
310
+ });
311
+ it("should get the next pending session", async () => {
312
+ const result = await getNextPendingSession({ sessionDir });
313
+ expect(result).toBeTruthy();
314
+ expect(result?.sessionId).toBe("first-session");
315
+ expect(result?.sessionRequest).toBeTruthy();
316
+ expect(result?.sessionRequest.sessionId).toBe("first-session");
317
+ });
318
+ it("should return null when no pending sessions", async () => {
319
+ // Clear all sessions
320
+ await fs.rm(sessionDir, { recursive: true });
321
+ await fs.mkdir(sessionDir);
322
+ const result = await getNextPendingSession({ sessionDir });
323
+ expect(result).toBeNull();
324
+ });
325
+ it("should handle sessions with corrupted data", async () => {
326
+ // Create session with corrupted request.json
327
+ const corruptedSessionDir = join(sessionDir, "corrupted-session");
328
+ await fs.mkdir(corruptedSessionDir);
329
+ const statusFile = join(corruptedSessionDir, SESSION_FILES.STATUS);
330
+ await fs.writeFile(statusFile, JSON.stringify({
331
+ createdAt: new Date().toISOString(),
332
+ lastModified: new Date().toISOString(),
333
+ sessionId: "corrupted-session",
334
+ status: "pending",
335
+ totalQuestions: 1,
336
+ }));
337
+ // Create corrupted request.json
338
+ const requestFile = join(corruptedSessionDir, SESSION_FILES.REQUEST);
339
+ await fs.writeFile(requestFile, "invalid json");
340
+ const result = await getNextPendingSession({ sessionDir });
341
+ // Should skip corrupted session and return valid one
342
+ expect(result).toBeTruthy();
343
+ expect(result?.sessionId).toBe("first-session");
344
+ });
345
+ });
346
+ });
347
+ describe("Error Handling", () => {
348
+ it("should handle directory access errors", async () => {
349
+ const watcher = new EnhancedTUISessionWatcher({
350
+ sessionDir: "/invalid/directory/path",
351
+ });
352
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => { });
353
+ const pendingSessions = await watcher.getPendingSessions();
354
+ expect(pendingSessions).toEqual([]);
355
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to scan for pending sessions"), expect.any(Error));
356
+ consoleSpy.mockRestore();
357
+ watcher.stop();
358
+ });
359
+ it("should handle missing watched path gracefully", async () => {
360
+ const watcher = new EnhancedTUISessionWatcher({
361
+ sessionDir: "/non/existent/path",
362
+ });
363
+ const sessionRequest = await watcher.getSessionRequest("any-id");
364
+ expect(sessionRequest).toBeNull();
365
+ watcher.stop();
366
+ });
367
+ });
368
+ });
@@ -0,0 +1,45 @@
1
+ import { Text } from "ink";
2
+ import gradient from "gradient-string";
3
+ import React, { useEffect, useState } from "react";
4
+ /**
5
+ * AnimatedGradient component - creates the smoothest possible flowing gradient effect
6
+ * Uses high-frequency updates (60 FPS) with many color stops for visible smoothness
7
+ * The gradient flows from right to left
8
+ */
9
+ export const AnimatedGradient = ({ text, flowSpeed = 0.3, // Speed of flow
10
+ }) => {
11
+ const [frame, setFrame] = useState(0);
12
+ // Generate a large color palette with many intermediate shades for smooth transitions
13
+ const generateColorStops = (numStops = 100) => {
14
+ const stops = [];
15
+ for (let i = 0; i < numStops; i++) {
16
+ const t = i / numStops;
17
+ // Create a wave pattern with sparse white peaks (2 peaks across the gradient)
18
+ const wave = Math.sin(t * Math.PI * 2 * 2) * 0.5 + 0.5;
19
+ // Apply power function to make white peaks sharper and briefer
20
+ const sharpWave = Math.pow(wave, 4); // Quartic power makes whites very brief
21
+ // Interpolate between light gray (175) and white (255)
22
+ const value = Math.floor(175 + (255 - 175) * sharpWave);
23
+ const hex = value.toString(16).padStart(2, "0");
24
+ stops.push(`#${hex}${hex}${hex}`);
25
+ }
26
+ return stops;
27
+ };
28
+ const colors = generateColorStops(100);
29
+ useEffect(() => {
30
+ // Run at 60 FPS for smooth updates - reversed direction
31
+ const interval = setInterval(() => {
32
+ setFrame((prev) => (prev - flowSpeed + colors.length) % colors.length);
33
+ }, 1000 / 60);
34
+ return () => clearInterval(interval);
35
+ }, [colors.length, flowSpeed]);
36
+ // Rotate colors array based on current frame to create flowing effect
37
+ const frameIndex = Math.floor(frame);
38
+ const rotatedColors = [
39
+ ...colors.slice(frameIndex),
40
+ ...colors.slice(0, frameIndex),
41
+ ];
42
+ const gradientFn = gradient(rotatedColors);
43
+ const styledText = gradientFn(text);
44
+ return React.createElement(Text, null, styledText);
45
+ };
@@ -0,0 +1,89 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import React, { useState } from "react";
3
+ import { theme } from "../theme.js";
4
+ import { MultiLineTextInput } from "./MultiLineTextInput.js";
5
+ /**
6
+ * ConfirmationDialog shows a 3-option prompt for session rejection
7
+ * Options: Reject & inform AI, Cancel, or Quit CLI
8
+ * If user chooses to reject, shows a two-step flow to optionally collect rejection reason
9
+ */
10
+ export const ConfirmationDialog = ({ message, onReject, onCancel, onQuit, }) => {
11
+ const [focusedIndex, setFocusedIndex] = useState(0);
12
+ const [showReasonInput, setShowReasonInput] = useState(false);
13
+ const [rejectionReason, setRejectionReason] = useState("");
14
+ const handleReasonSubmit = () => {
15
+ onReject(rejectionReason.trim() || null);
16
+ };
17
+ const handleSkipReason = () => {
18
+ onReject(null);
19
+ };
20
+ const options = [
21
+ { key: "y", label: "Yes, inform the AI that I rejected this question set", action: () => setShowReasonInput(true) },
22
+ { key: "n", label: "No, go back to answering questions", action: onCancel },
23
+ { key: "q", label: "I'm just trying to quit the CLI, I'll answer later", action: onQuit },
24
+ ];
25
+ useInput((input, key) => {
26
+ // If in reason input mode, handle Esc to skip
27
+ if (showReasonInput) {
28
+ if (key.escape) {
29
+ handleSkipReason();
30
+ }
31
+ return; // Let MultiLineTextInput handle other keys
32
+ }
33
+ // Arrow key navigation
34
+ if (key.upArrow) {
35
+ setFocusedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1));
36
+ }
37
+ if (key.downArrow) {
38
+ setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0));
39
+ }
40
+ // Enter key - select focused option
41
+ if (key.return) {
42
+ options[focusedIndex].action();
43
+ }
44
+ // Letter shortcuts
45
+ if (input === "y" || input === "Y") {
46
+ setShowReasonInput(true);
47
+ }
48
+ if (input === "n" || input === "N") {
49
+ onCancel();
50
+ }
51
+ if (input === "q" || input === "Q") {
52
+ onQuit();
53
+ }
54
+ // Esc key - same as quit
55
+ if (key.escape) {
56
+ onQuit();
57
+ }
58
+ });
59
+ // Step 2: Reason input screen
60
+ if (showReasonInput) {
61
+ return (React.createElement(Box, { borderColor: theme.borders.warning, borderStyle: "single", flexDirection: "column", padding: 1 },
62
+ React.createElement(Box, { marginBottom: 1 },
63
+ React.createElement(Text, { bold: true, color: theme.colors.warning }, "Why are you rejecting this question set?")),
64
+ React.createElement(Box, { marginBottom: 1 },
65
+ React.createElement(Text, { dimColor: true }, "(Optional - helps the AI improve)")),
66
+ React.createElement(Box, { marginBottom: 1 },
67
+ React.createElement(MultiLineTextInput, { isFocused: true, onChange: setRejectionReason, onSubmit: handleReasonSubmit, placeholder: "Type your reason here...", value: rejectionReason })),
68
+ React.createElement(Box, { marginTop: 1 },
69
+ React.createElement(Text, { dimColor: true }, "Enter Submit | Shift+Enter Newline | Esc Skip"))));
70
+ }
71
+ // Step 1: Confirmation options
72
+ return (React.createElement(Box, { borderColor: theme.borders.warning, borderStyle: "single", flexDirection: "column", padding: 1 },
73
+ React.createElement(Box, { marginBottom: 1 },
74
+ React.createElement(Text, { bold: true, color: theme.colors.warning }, message)),
75
+ options.map((option, index) => {
76
+ const isFocused = index === focusedIndex;
77
+ return (React.createElement(Box, { key: index, marginTop: index > 0 ? 0.5 : 0 },
78
+ React.createElement(Text, { bold: isFocused, color: isFocused ? theme.colors.focused : theme.colors.text },
79
+ isFocused ? "→ " : " ",
80
+ index + 1,
81
+ ". ",
82
+ option.label,
83
+ " (",
84
+ option.key,
85
+ ")")));
86
+ }),
87
+ React.createElement(Box, { marginTop: 1 },
88
+ React.createElement(Text, { dimColor: true }, "\u2191\u2193 Navigate | Enter Select | y/n/q Shortcuts"))));
89
+ };