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.
- package/LICENSE +25 -0
- package/README.md +176 -0
- package/dist/__tests__/schema-validation.test.js +137 -0
- package/dist/__tests__/server.integration.test.js +263 -0
- package/dist/add.js +1 -0
- package/dist/add.test.js +5 -0
- package/dist/bin/auq.js +245 -0
- package/dist/bin/test-session-menu.js +28 -0
- package/dist/bin/test-tabbar.js +42 -0
- package/dist/file-utils.js +59 -0
- package/dist/format/ResponseFormatter.js +206 -0
- package/dist/format/__tests__/ResponseFormatter.test.js +380 -0
- package/dist/package.json +74 -0
- package/dist/server.js +107 -0
- package/dist/session/ResponseFormatter.js +130 -0
- package/dist/session/SessionManager.js +474 -0
- package/dist/session/__tests__/ResponseFormatter.test.js +417 -0
- package/dist/session/__tests__/SessionManager.test.js +553 -0
- package/dist/session/__tests__/atomic-operations.test.js +345 -0
- package/dist/session/__tests__/file-watcher.test.js +311 -0
- package/dist/session/__tests__/workflow.integration.test.js +334 -0
- package/dist/session/atomic-operations.js +307 -0
- package/dist/session/file-watcher.js +218 -0
- package/dist/session/index.js +7 -0
- package/dist/session/types.js +20 -0
- package/dist/session/utils.js +125 -0
- package/dist/session-manager.js +171 -0
- package/dist/session-watcher.js +110 -0
- package/dist/src/__tests__/schema-validation.test.js +170 -0
- package/dist/src/__tests__/server.integration.test.js +274 -0
- package/dist/src/add.js +1 -0
- package/dist/src/add.test.js +5 -0
- package/dist/src/server.js +163 -0
- package/dist/src/session/ResponseFormatter.js +163 -0
- package/dist/src/session/SessionManager.js +572 -0
- package/dist/src/session/__tests__/ResponseFormatter.test.js +741 -0
- package/dist/src/session/__tests__/SessionManager.test.js +593 -0
- package/dist/src/session/__tests__/atomic-operations.test.js +346 -0
- package/dist/src/session/__tests__/file-watcher.test.js +311 -0
- package/dist/src/session/atomic-operations.js +307 -0
- package/dist/src/session/file-watcher.js +227 -0
- package/dist/src/session/index.js +7 -0
- package/dist/src/session/types.js +20 -0
- package/dist/src/session/utils.js +180 -0
- package/dist/src/tui/__tests__/session-watcher.test.js +368 -0
- package/dist/src/tui/components/AnimatedGradient.js +45 -0
- package/dist/src/tui/components/ConfirmationDialog.js +89 -0
- package/dist/src/tui/components/CustomInput.js +14 -0
- package/dist/src/tui/components/Footer.js +55 -0
- package/dist/src/tui/components/Header.js +35 -0
- package/dist/src/tui/components/MultiLineTextInput.js +65 -0
- package/dist/src/tui/components/OptionsList.js +115 -0
- package/dist/src/tui/components/QuestionDisplay.js +36 -0
- package/dist/src/tui/components/ReviewScreen.js +57 -0
- package/dist/src/tui/components/SessionSelectionMenu.js +151 -0
- package/dist/src/tui/components/StepperView.js +166 -0
- package/dist/src/tui/components/TabBar.js +42 -0
- package/dist/src/tui/components/Toast.js +19 -0
- package/dist/src/tui/components/WaitingScreen.js +20 -0
- package/dist/src/tui/session-watcher.js +195 -0
- package/dist/src/tui/theme.js +114 -0
- package/dist/src/tui/utils/gradientText.js +24 -0
- package/dist/tui/__tests__/session-watcher.test.js +368 -0
- package/dist/tui/session-watcher.js +183 -0
- package/package.json +78 -0
- 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
|
+
};
|