auq-mcp-server 2.3.0 → 2.5.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/README.md +122 -0
- package/dist/bin/auq.js +87 -93
- package/dist/bin/tui-app.js +183 -7
- package/dist/package.json +1 -1
- package/dist/src/__tests__/server.abort.test.js +214 -0
- package/dist/src/cli/commands/__tests__/answer.test.js +199 -0
- package/dist/src/cli/commands/__tests__/config.test.js +218 -0
- package/dist/src/cli/commands/__tests__/sessions.test.js +282 -0
- package/dist/src/cli/commands/answer.js +128 -0
- package/dist/src/cli/commands/config.js +263 -0
- package/dist/src/cli/commands/sessions.js +300 -0
- package/dist/src/cli/commands/update.js +124 -0
- package/dist/src/cli/utils.js +95 -0
- package/dist/src/config/__tests__/ConfigLoader.test.js +41 -0
- package/dist/src/config/__tests__/updateCheck.test.js +34 -0
- package/dist/src/config/defaults.js +5 -0
- package/dist/src/config/types.js +6 -0
- package/dist/src/core/ask-user-questions.js +3 -2
- package/dist/src/i18n/locales/en.js +7 -0
- package/dist/src/i18n/locales/ko.js +7 -0
- package/dist/src/server.js +64 -11
- package/dist/src/session/SessionManager.js +69 -4
- package/dist/src/session/__tests__/SessionManager.test.js +65 -0
- package/dist/src/tui/__tests__/session-watcher.test.js +109 -0
- package/dist/src/tui/components/Footer.js +4 -1
- package/dist/src/tui/components/Header.js +3 -1
- package/dist/src/tui/components/SessionDots.js +33 -4
- package/dist/src/tui/components/SessionPicker.js +25 -17
- package/dist/src/tui/components/StepperView.js +68 -5
- package/dist/src/tui/components/UpdateBadge.js +29 -0
- package/dist/src/tui/components/UpdateOverlay.js +199 -0
- package/dist/src/tui/components/__tests__/SessionDots.test.js +160 -1
- package/dist/src/tui/components/__tests__/SessionPicker.test.js +43 -1
- package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +160 -0
- package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -0
- package/dist/src/tui/constants/keybindings.js +3 -0
- package/dist/src/tui/session-watcher.js +50 -0
- package/dist/src/tui/themes/catppuccin-latte.js +7 -0
- package/dist/src/tui/themes/catppuccin-mocha.js +7 -0
- package/dist/src/tui/themes/dark.js +7 -0
- package/dist/src/tui/themes/dracula.js +7 -0
- package/dist/src/tui/themes/github-dark.js +7 -0
- package/dist/src/tui/themes/github-light.js +7 -0
- package/dist/src/tui/themes/gruvbox-dark.js +7 -0
- package/dist/src/tui/themes/gruvbox-light.js +7 -0
- package/dist/src/tui/themes/light.js +7 -0
- package/dist/src/tui/themes/monokai.js +7 -0
- package/dist/src/tui/themes/nord.js +7 -0
- package/dist/src/tui/themes/one-dark.js +7 -0
- package/dist/src/tui/themes/rose-pine.js +7 -0
- package/dist/src/tui/themes/solarized-dark.js +7 -0
- package/dist/src/tui/themes/solarized-light.js +7 -0
- package/dist/src/tui/themes/tokyo-night.js +7 -0
- package/dist/src/tui/utils/__tests__/staleDetection.test.js +118 -0
- package/dist/src/tui/utils/staleDetection.js +51 -0
- package/dist/src/update/__tests__/cache.test.js +136 -0
- package/dist/src/update/__tests__/changelog.test.js +86 -0
- package/dist/src/update/__tests__/checker.test.js +148 -0
- package/dist/src/update/__tests__/index.test.js +37 -0
- package/dist/src/update/__tests__/installer.test.js +117 -0
- package/dist/src/update/__tests__/package-manager.test.js +73 -0
- package/dist/src/update/__tests__/version.test.js +74 -0
- package/dist/src/update/cache.js +74 -0
- package/dist/src/update/changelog.js +63 -0
- package/dist/src/update/checker.js +121 -0
- package/dist/src/update/index.js +15 -0
- package/dist/src/update/installer.js +51 -0
- package/dist/src/update/package-manager.js +49 -0
- package/dist/src/update/types.js +7 -0
- package/dist/src/update/version.js +114 -0
- package/package.json +1 -1
|
@@ -39,6 +39,7 @@ export const ko = {
|
|
|
39
39
|
copied: "클립보드에 복사됨",
|
|
40
40
|
saved: "저장됨",
|
|
41
41
|
error: "오류",
|
|
42
|
+
staleSession: "세션 \"{title}\"이 고아 상태일 수 있습니다 ({hours}시간 전 생성)",
|
|
42
43
|
},
|
|
43
44
|
stepper: {
|
|
44
45
|
submitting: "답변 제출 중...",
|
|
@@ -72,4 +73,10 @@ export const ko = {
|
|
|
72
73
|
ui: {
|
|
73
74
|
themeLabel: "테마:",
|
|
74
75
|
},
|
|
76
|
+
abandoned: {
|
|
77
|
+
title: "AI 연결 끊김",
|
|
78
|
+
message: "AI가 disconnect되었습니다. 그래도 답변하시겠습니까?",
|
|
79
|
+
continue: "답변하기",
|
|
80
|
+
cancel: "취소",
|
|
81
|
+
},
|
|
75
82
|
};
|
package/dist/src/server.js
CHANGED
|
@@ -3,6 +3,8 @@ import { randomUUID } from "crypto";
|
|
|
3
3
|
import { AskUserQuestionsParametersSchema, createAskUserQuestionsCore, } from "./core/ask-user-questions.js";
|
|
4
4
|
import { TOOL_DESCRIPTION } from "./shared/schemas.js";
|
|
5
5
|
const askUserQuestionsCore = createAskUserQuestionsCore();
|
|
6
|
+
// Track active requests with their AbortControllers for disconnect handling
|
|
7
|
+
const activeRequests = new Map();
|
|
6
8
|
const server = new FastMCP({
|
|
7
9
|
name: "AskUserQuestions",
|
|
8
10
|
instructions: "MCP server for asking users structured questions during AI execution. " +
|
|
@@ -41,22 +43,50 @@ server.addTool({
|
|
|
41
43
|
}
|
|
42
44
|
// Generate a per-tool-call ID and persist it with the session
|
|
43
45
|
const callId = randomUUID();
|
|
46
|
+
// Create AbortController for this request to handle disconnects
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
activeRequests.set(callId, { controller });
|
|
44
49
|
// Capture working directory if available from MCP context
|
|
45
50
|
// Note: MCP protocol does not currently expose client working directory
|
|
46
51
|
// This field is reserved for future protocol enhancements
|
|
47
52
|
const workingDirectory = ctx
|
|
48
53
|
.workingDirectory;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
try {
|
|
55
|
+
const { formattedResponse, sessionId } = await askUserQuestionsCore.ask(args.questions, callId, workingDirectory, controller.signal);
|
|
56
|
+
// Update entry with sessionId for disconnect handler
|
|
57
|
+
const entry = activeRequests.get(callId);
|
|
58
|
+
if (entry) {
|
|
59
|
+
entry.sessionId = sessionId;
|
|
60
|
+
}
|
|
61
|
+
log.info("Session completed successfully", { sessionId, callId });
|
|
62
|
+
// Return formatted response to AI model
|
|
63
|
+
return {
|
|
64
|
+
content: [
|
|
65
|
+
{
|
|
66
|
+
text: formattedResponse,
|
|
67
|
+
type: "text",
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
// Handle abort (AI client disconnected)
|
|
74
|
+
if (error instanceof Error && error.message === "ABORTED") {
|
|
75
|
+
log.warn("Session aborted: AI client disconnected", { callId });
|
|
76
|
+
return {
|
|
77
|
+
content: [
|
|
78
|
+
{
|
|
79
|
+
text: "Session aborted: AI client disconnected",
|
|
80
|
+
type: "text",
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
throw error; // Re-throw other errors to outer catch
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
activeRequests.delete(callId);
|
|
89
|
+
}
|
|
60
90
|
}
|
|
61
91
|
catch (error) {
|
|
62
92
|
log.error("Session failed", { error: String(error) });
|
|
@@ -72,6 +102,29 @@ server.addTool({
|
|
|
72
102
|
},
|
|
73
103
|
parameters: AskUserQuestionsParametersSchema,
|
|
74
104
|
});
|
|
105
|
+
// Handle AI client disconnections gracefully
|
|
106
|
+
// Note: FastMCP disconnect event support depends on the version.
|
|
107
|
+
// If the event is not available, stale detection handles orphaned sessions as fallback.
|
|
108
|
+
try {
|
|
109
|
+
server.on("disconnect", async () => {
|
|
110
|
+
for (const [callId, entry] of activeRequests.entries()) {
|
|
111
|
+
try {
|
|
112
|
+
entry.controller.abort();
|
|
113
|
+
if (entry.sessionId) {
|
|
114
|
+
await askUserQuestionsCore.markAbandoned(entry.sessionId).catch(() => { });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Silently ignore errors during disconnect cleanup
|
|
119
|
+
}
|
|
120
|
+
activeRequests.delete(callId);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// FastMCP version may not support disconnect events
|
|
126
|
+
// Graceful fallback: stale detection handles orphaned sessions
|
|
127
|
+
}
|
|
75
128
|
// Start the server with stdio transport
|
|
76
129
|
server.start({
|
|
77
130
|
transportType: "stdio",
|
|
@@ -141,6 +141,29 @@ export class SessionManager {
|
|
|
141
141
|
async getSessionAnswers(sessionId) {
|
|
142
142
|
return this.readSessionFile(sessionId, SESSION_FILES.ANSWERS);
|
|
143
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Get all pending sessions, optionally including abandoned ones
|
|
146
|
+
*/
|
|
147
|
+
async getPendingSessions(options) {
|
|
148
|
+
const sessionIds = await this.getAllSessionIds();
|
|
149
|
+
const pendingSessions = [];
|
|
150
|
+
for (const sessionId of sessionIds) {
|
|
151
|
+
try {
|
|
152
|
+
const status = await this.getSessionStatus(sessionId);
|
|
153
|
+
if (!status)
|
|
154
|
+
continue;
|
|
155
|
+
const isPending = status.status === "pending" || status.status === "in-progress";
|
|
156
|
+
const isAbandoned = status.status === "abandoned";
|
|
157
|
+
if (isPending || (options?.includeAbandoned && isAbandoned)) {
|
|
158
|
+
pendingSessions.push(sessionId);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return pendingSessions;
|
|
166
|
+
}
|
|
144
167
|
/**
|
|
145
168
|
* Get session count
|
|
146
169
|
*/
|
|
@@ -160,6 +183,13 @@ export class SessionManager {
|
|
|
160
183
|
async getSessionStatus(sessionId) {
|
|
161
184
|
return this.readSessionFile(sessionId, SESSION_FILES.STATUS);
|
|
162
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Check if a session has been abandoned
|
|
188
|
+
*/
|
|
189
|
+
async isAbandoned(sessionId) {
|
|
190
|
+
const status = await this.getSessionStatus(sessionId);
|
|
191
|
+
return status?.status === "abandoned";
|
|
192
|
+
}
|
|
163
193
|
/**
|
|
164
194
|
* Initialize the session manager - create base directories
|
|
165
195
|
*/
|
|
@@ -239,9 +269,21 @@ export class SessionManager {
|
|
|
239
269
|
* @returns Object containing sessionId and formatted response text
|
|
240
270
|
* @throws Error if timeout occurs, validation fails, or file operations fail
|
|
241
271
|
*/
|
|
242
|
-
async startSession(questions, callId, workingDirectory) {
|
|
272
|
+
async startSession(questions, callId, workingDirectory, signal) {
|
|
243
273
|
// Step 1: Create the session
|
|
244
274
|
const sessionId = await this.createSession(questions, workingDirectory);
|
|
275
|
+
// Step 1.5: Register abort handler if signal provided
|
|
276
|
+
let abortHandler;
|
|
277
|
+
if (signal) {
|
|
278
|
+
if (signal.aborted) {
|
|
279
|
+
await this.updateSessionStatus(sessionId, "abandoned");
|
|
280
|
+
throw new Error("ABORTED");
|
|
281
|
+
}
|
|
282
|
+
abortHandler = () => {
|
|
283
|
+
this.updateSessionStatus(sessionId, "abandoned").catch(() => { });
|
|
284
|
+
};
|
|
285
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
286
|
+
}
|
|
245
287
|
// Optionally attach callId and workingDirectory metadata to request and status
|
|
246
288
|
if (callId || workingDirectory) {
|
|
247
289
|
try {
|
|
@@ -274,11 +316,23 @@ export class SessionManager {
|
|
|
274
316
|
: 0; // Also infinite if session is infinite
|
|
275
317
|
// Step 3: Wait for answers with timeout
|
|
276
318
|
try {
|
|
277
|
-
await this.waitForAnswers(sessionId, watcherTimeout, callId);
|
|
319
|
+
await this.waitForAnswers(sessionId, watcherTimeout, callId, signal);
|
|
278
320
|
}
|
|
279
321
|
catch (error) {
|
|
322
|
+
// Check if session was aborted (AI disconnected)
|
|
323
|
+
if (error instanceof Error && error.message === "ABORTED") {
|
|
324
|
+
// Clean up abort handler
|
|
325
|
+
if (abortHandler && signal) {
|
|
326
|
+
signal.removeEventListener("abort", abortHandler);
|
|
327
|
+
}
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
280
330
|
// Check if session was rejected by user
|
|
281
331
|
if (error instanceof Error && error.message === "SESSION_REJECTED") {
|
|
332
|
+
// Clean up abort handler
|
|
333
|
+
if (abortHandler && signal) {
|
|
334
|
+
signal.removeEventListener("abort", abortHandler);
|
|
335
|
+
}
|
|
282
336
|
// Get session status to retrieve rejection reason
|
|
283
337
|
const status = await this.getSessionStatus(sessionId);
|
|
284
338
|
const reason = status?.rejectionReason;
|
|
@@ -290,7 +344,6 @@ export class SessionManager {
|
|
|
290
344
|
else {
|
|
291
345
|
formattedResponse += "No reason provided.\n\n";
|
|
292
346
|
}
|
|
293
|
-
// formattedResponse += "The user chose not to answer these questions at this time.";
|
|
294
347
|
return {
|
|
295
348
|
formattedResponse,
|
|
296
349
|
sessionId,
|
|
@@ -331,6 +384,10 @@ export class SessionManager {
|
|
|
331
384
|
const formattedResponse = ResponseFormatter.formatUserResponse(answers, request.questions);
|
|
332
385
|
// Step 7: Update final status
|
|
333
386
|
await this.updateSessionStatus(sessionId, "completed");
|
|
387
|
+
// Clean up abort handler after successful completion
|
|
388
|
+
if (abortHandler && signal) {
|
|
389
|
+
signal.removeEventListener("abort", abortHandler);
|
|
390
|
+
}
|
|
334
391
|
// Step 8: Return results
|
|
335
392
|
return {
|
|
336
393
|
formattedResponse,
|
|
@@ -338,6 +395,10 @@ export class SessionManager {
|
|
|
338
395
|
};
|
|
339
396
|
}
|
|
340
397
|
catch (error) {
|
|
398
|
+
// Clean up abort handler on error
|
|
399
|
+
if (abortHandler && signal) {
|
|
400
|
+
signal.removeEventListener("abort", abortHandler);
|
|
401
|
+
}
|
|
341
402
|
// Ensure any errors are properly propagated with session context
|
|
342
403
|
if (error instanceof Error) {
|
|
343
404
|
throw error;
|
|
@@ -416,7 +477,7 @@ export class SessionManager {
|
|
|
416
477
|
* Wait for user answers to be submitted for a specific session
|
|
417
478
|
* Returns the session ID when answers are detected, or rejects on timeout
|
|
418
479
|
*/
|
|
419
|
-
async waitForAnswers(sessionId, timeoutMs, expectedCallId) {
|
|
480
|
+
async waitForAnswers(sessionId, timeoutMs, expectedCallId, signal) {
|
|
420
481
|
const sessionDir = this.getSessionDir(sessionId);
|
|
421
482
|
const answersPath = join(sessionDir, SESSION_FILES.ANSWERS);
|
|
422
483
|
const startTime = Date.now();
|
|
@@ -448,6 +509,10 @@ export class SessionManager {
|
|
|
448
509
|
if (status && status.status === "rejected") {
|
|
449
510
|
throw new Error("SESSION_REJECTED");
|
|
450
511
|
}
|
|
512
|
+
// Check for abort signal
|
|
513
|
+
if (signal?.aborted) {
|
|
514
|
+
throw new Error("ABORTED");
|
|
515
|
+
}
|
|
451
516
|
// Check for timeout
|
|
452
517
|
if (timeoutMs && timeoutMs > 0 && Date.now() - startTime > timeoutMs) {
|
|
453
518
|
throw new Error("Timeout waiting for user response");
|
|
@@ -590,4 +590,69 @@ describe("SessionManager", () => {
|
|
|
590
590
|
expect(result2.formattedResponse).toContain("Option 2");
|
|
591
591
|
});
|
|
592
592
|
});
|
|
593
|
+
describe("abandoned status support", () => {
|
|
594
|
+
const sampleQuestions = [
|
|
595
|
+
{ options: [{ label: "Opt" }], prompt: "Test", title: "Test" },
|
|
596
|
+
];
|
|
597
|
+
it("should transition a session to abandoned status via updateSessionStatus", async () => {
|
|
598
|
+
const sessionId = await sessionManager.createSession(sampleQuestions);
|
|
599
|
+
await sessionManager.updateSessionStatus(sessionId, "abandoned");
|
|
600
|
+
const status = await sessionManager.getSessionStatus(sessionId);
|
|
601
|
+
expect(status?.status).toBe("abandoned");
|
|
602
|
+
});
|
|
603
|
+
it("should return true from isAbandoned() for abandoned session", async () => {
|
|
604
|
+
const sessionId = await sessionManager.createSession(sampleQuestions);
|
|
605
|
+
await sessionManager.updateSessionStatus(sessionId, "abandoned");
|
|
606
|
+
const result = await sessionManager.isAbandoned(sessionId);
|
|
607
|
+
expect(result).toBe(true);
|
|
608
|
+
});
|
|
609
|
+
it("should return false from isAbandoned() for pending session", async () => {
|
|
610
|
+
const sessionId = await sessionManager.createSession(sampleQuestions);
|
|
611
|
+
const result = await sessionManager.isAbandoned(sessionId);
|
|
612
|
+
expect(result).toBe(false);
|
|
613
|
+
});
|
|
614
|
+
it("should return false from isAbandoned() for non-existent session", async () => {
|
|
615
|
+
const result = await sessionManager.isAbandoned("non-existent-session-id");
|
|
616
|
+
expect(result).toBe(false);
|
|
617
|
+
});
|
|
618
|
+
it("should exclude abandoned sessions from getPendingSessions() by default", async () => {
|
|
619
|
+
const pendingId = await sessionManager.createSession(sampleQuestions);
|
|
620
|
+
const abandonedId = await sessionManager.createSession(sampleQuestions);
|
|
621
|
+
await sessionManager.updateSessionStatus(abandonedId, "abandoned");
|
|
622
|
+
const pending = await sessionManager.getPendingSessions();
|
|
623
|
+
expect(pending).toContain(pendingId);
|
|
624
|
+
expect(pending).not.toContain(abandonedId);
|
|
625
|
+
});
|
|
626
|
+
it("should include abandoned sessions in getPendingSessions() when includeAbandoned is true", async () => {
|
|
627
|
+
const pendingId = await sessionManager.createSession(sampleQuestions);
|
|
628
|
+
const abandonedId = await sessionManager.createSession(sampleQuestions);
|
|
629
|
+
await sessionManager.updateSessionStatus(abandonedId, "abandoned");
|
|
630
|
+
const pending = await sessionManager.getPendingSessions({ includeAbandoned: true });
|
|
631
|
+
expect(pending).toContain(pendingId);
|
|
632
|
+
expect(pending).toContain(abandonedId);
|
|
633
|
+
});
|
|
634
|
+
it("should exclude completed sessions from getPendingSessions() even with includeAbandoned", async () => {
|
|
635
|
+
const pendingId = await sessionManager.createSession(sampleQuestions);
|
|
636
|
+
const completedId = await sessionManager.createSession(sampleQuestions);
|
|
637
|
+
const abandonedId = await sessionManager.createSession(sampleQuestions);
|
|
638
|
+
await sessionManager.updateSessionStatus(completedId, "completed");
|
|
639
|
+
await sessionManager.updateSessionStatus(abandonedId, "abandoned");
|
|
640
|
+
const pending = await sessionManager.getPendingSessions({ includeAbandoned: true });
|
|
641
|
+
expect(pending).toContain(pendingId);
|
|
642
|
+
expect(pending).toContain(abandonedId);
|
|
643
|
+
expect(pending).not.toContain(completedId);
|
|
644
|
+
});
|
|
645
|
+
it("should include in-progress sessions in getPendingSessions()", async () => {
|
|
646
|
+
const inProgressId = await sessionManager.createSession(sampleQuestions);
|
|
647
|
+
await sessionManager.updateSessionStatus(inProgressId, "in-progress");
|
|
648
|
+
const pending = await sessionManager.getPendingSessions();
|
|
649
|
+
expect(pending).toContain(inProgressId);
|
|
650
|
+
});
|
|
651
|
+
it("should return empty array from getPendingSessions() when no pending sessions exist", async () => {
|
|
652
|
+
const completedId = await sessionManager.createSession(sampleQuestions);
|
|
653
|
+
await sessionManager.updateSessionStatus(completedId, "completed");
|
|
654
|
+
const pending = await sessionManager.getPendingSessions();
|
|
655
|
+
expect(pending).toEqual([]);
|
|
656
|
+
});
|
|
657
|
+
});
|
|
593
658
|
});
|
|
@@ -270,6 +270,115 @@ describe("TUI Session Watcher", () => {
|
|
|
270
270
|
watcher.stop();
|
|
271
271
|
});
|
|
272
272
|
});
|
|
273
|
+
describe("getPendingSessionsWithStatus", () => {
|
|
274
|
+
beforeEach(async () => {
|
|
275
|
+
// Create test sessions with various statuses
|
|
276
|
+
const sessions = [
|
|
277
|
+
{ id: "session-1", status: "pending", completed: false },
|
|
278
|
+
{ id: "session-2", status: "completed", completed: true },
|
|
279
|
+
{ id: "session-3", status: "pending", completed: false },
|
|
280
|
+
];
|
|
281
|
+
for (const session of sessions) {
|
|
282
|
+
const dir = join(sessionDir, session.id);
|
|
283
|
+
await fs.mkdir(dir, { recursive: true });
|
|
284
|
+
const requestFile = join(dir, SESSION_FILES.REQUEST);
|
|
285
|
+
await fs.writeFile(requestFile, JSON.stringify({
|
|
286
|
+
...mockSessionRequest,
|
|
287
|
+
sessionId: session.id,
|
|
288
|
+
}));
|
|
289
|
+
const statusFile = join(dir, SESSION_FILES.STATUS);
|
|
290
|
+
await fs.writeFile(statusFile, JSON.stringify({
|
|
291
|
+
createdAt: new Date().toISOString(),
|
|
292
|
+
lastModified: new Date().toISOString(),
|
|
293
|
+
sessionId: session.id,
|
|
294
|
+
status: session.status,
|
|
295
|
+
totalQuestions: 1,
|
|
296
|
+
}));
|
|
297
|
+
if (session.completed) {
|
|
298
|
+
const answersFile = join(dir, SESSION_FILES.ANSWERS);
|
|
299
|
+
await fs.writeFile(answersFile, JSON.stringify({
|
|
300
|
+
answers: [
|
|
301
|
+
{
|
|
302
|
+
questionIndex: 0,
|
|
303
|
+
selectedOption: "JavaScript",
|
|
304
|
+
timestamp: new Date().toISOString(),
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
sessionId: session.id,
|
|
308
|
+
timestamp: new Date().toISOString(),
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
it("should return pending and in-progress sessions with metadata", async () => {
|
|
314
|
+
const watcher = new EnhancedTUISessionWatcher({ sessionDir });
|
|
315
|
+
const sessions = await watcher.getPendingSessionsWithStatus();
|
|
316
|
+
expect(sessions).toHaveLength(2);
|
|
317
|
+
expect(sessions[0].sessionId).toBe("session-1");
|
|
318
|
+
expect(sessions[0].status).toBe("pending");
|
|
319
|
+
expect(sessions[0].createdAt).toBeDefined();
|
|
320
|
+
expect(sessions[1].sessionId).toBe("session-3");
|
|
321
|
+
expect(sessions[1].status).toBe("pending");
|
|
322
|
+
watcher.stop();
|
|
323
|
+
});
|
|
324
|
+
it("should include abandoned sessions", async () => {
|
|
325
|
+
// Create an abandoned session
|
|
326
|
+
const abandonedDir = join(sessionDir, "session-4-abandoned");
|
|
327
|
+
await fs.mkdir(abandonedDir, { recursive: true });
|
|
328
|
+
const requestFile = join(abandonedDir, SESSION_FILES.REQUEST);
|
|
329
|
+
const statusFile = join(abandonedDir, SESSION_FILES.STATUS);
|
|
330
|
+
await Promise.all([
|
|
331
|
+
fs.writeFile(requestFile, JSON.stringify({
|
|
332
|
+
...mockSessionRequest,
|
|
333
|
+
sessionId: "session-4-abandoned",
|
|
334
|
+
})),
|
|
335
|
+
fs.writeFile(statusFile, JSON.stringify({
|
|
336
|
+
createdAt: new Date().toISOString(),
|
|
337
|
+
lastModified: new Date().toISOString(),
|
|
338
|
+
sessionId: "session-4-abandoned",
|
|
339
|
+
status: "abandoned",
|
|
340
|
+
totalQuestions: 1,
|
|
341
|
+
})),
|
|
342
|
+
]);
|
|
343
|
+
const watcher = new EnhancedTUISessionWatcher({ sessionDir });
|
|
344
|
+
const sessions = await watcher.getPendingSessionsWithStatus();
|
|
345
|
+
// Should have session-1 (pending), session-3 (pending), session-4-abandoned (abandoned)
|
|
346
|
+
expect(sessions).toHaveLength(3);
|
|
347
|
+
const abandoned = sessions.find((s) => s.sessionId === "session-4-abandoned");
|
|
348
|
+
expect(abandoned).toBeDefined();
|
|
349
|
+
expect(abandoned.status).toBe("abandoned");
|
|
350
|
+
watcher.stop();
|
|
351
|
+
});
|
|
352
|
+
it("should exclude completed, rejected, and timed_out sessions", async () => {
|
|
353
|
+
const watcher = new EnhancedTUISessionWatcher({ sessionDir });
|
|
354
|
+
const sessions = await watcher.getPendingSessionsWithStatus();
|
|
355
|
+
// session-2 is completed and has answers — should be excluded
|
|
356
|
+
const completed = sessions.find((s) => s.sessionId === "session-2");
|
|
357
|
+
expect(completed).toBeUndefined();
|
|
358
|
+
watcher.stop();
|
|
359
|
+
});
|
|
360
|
+
it("should return sorted results by sessionId", async () => {
|
|
361
|
+
const watcher = new EnhancedTUISessionWatcher({ sessionDir });
|
|
362
|
+
const sessions = await watcher.getPendingSessionsWithStatus();
|
|
363
|
+
const ids = sessions.map((s) => s.sessionId);
|
|
364
|
+
const sorted = [...ids].sort();
|
|
365
|
+
expect(ids).toEqual(sorted);
|
|
366
|
+
watcher.stop();
|
|
367
|
+
});
|
|
368
|
+
it("should handle directory access errors gracefully", async () => {
|
|
369
|
+
const watcher = new EnhancedTUISessionWatcher({
|
|
370
|
+
sessionDir: "/invalid/directory/path",
|
|
371
|
+
});
|
|
372
|
+
const consoleSpy = vi
|
|
373
|
+
.spyOn(console, "warn")
|
|
374
|
+
.mockImplementation(() => { });
|
|
375
|
+
const sessions = await watcher.getPendingSessionsWithStatus();
|
|
376
|
+
expect(sessions).toEqual([]);
|
|
377
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to scan for pending sessions with status"), expect.any(Error));
|
|
378
|
+
consoleSpy.mockRestore();
|
|
379
|
+
watcher.stop();
|
|
380
|
+
});
|
|
381
|
+
});
|
|
273
382
|
});
|
|
274
383
|
describe("Utility Functions", () => {
|
|
275
384
|
describe("createTUIWatcher", () => {
|
|
@@ -8,7 +8,7 @@ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧",
|
|
|
8
8
|
* Footer component - displays context-aware keybindings
|
|
9
9
|
* Shows different shortcuts based on current focus context and question type
|
|
10
10
|
*/
|
|
11
|
-
export const Footer = ({ focusContext, multiSelect, isReviewScreen = false, showSessionSwitching = false, customInputValue = "", hasRecommendedOptions = false, hasAnyRecommendedInSession = false, isSubmitting = false, }) => {
|
|
11
|
+
export const Footer = ({ focusContext, multiSelect, isReviewScreen = false, showSessionSwitching = false, customInputValue = "", hasRecommendedOptions = false, hasAnyRecommendedInSession = false, isSubmitting = false, hasUpdate = false, }) => {
|
|
12
12
|
const { theme } = useTheme();
|
|
13
13
|
const [spinnerFrame, setSpinnerFrame] = useState(0);
|
|
14
14
|
// Animate spinner when submitting
|
|
@@ -75,6 +75,9 @@ export const Footer = ({ focusContext, multiSelect, isReviewScreen = false, show
|
|
|
75
75
|
bindings.push({ key: KEY_LABELS.SESSION_LIST, action: t("footer.list") });
|
|
76
76
|
}
|
|
77
77
|
bindings.push({ key: KEY_LABELS.THEME, action: t("footer.theme") });
|
|
78
|
+
if (hasUpdate) {
|
|
79
|
+
bindings.push({ key: KEY_LABELS.UPDATE, action: "Update" });
|
|
80
|
+
}
|
|
78
81
|
bindings.push({ key: KEY_LABELS.REJECT, action: t("footer.reject") });
|
|
79
82
|
return bindings;
|
|
80
83
|
}
|
|
@@ -3,11 +3,12 @@ import React, { useEffect, useState } from "react";
|
|
|
3
3
|
import { t } from "../../i18n/index.js";
|
|
4
4
|
import { useTheme } from "../ThemeContext.js";
|
|
5
5
|
import packageJson from "../../../package.json" with { type: "json" };
|
|
6
|
+
import { UpdateBadge } from "./UpdateBadge.js";
|
|
6
7
|
/**
|
|
7
8
|
* Header component - displays app logo and status
|
|
8
9
|
* Shows at the top of the TUI with gradient branding and live-updating pending queue count
|
|
9
10
|
*/
|
|
10
|
-
export const Header = ({ pendingCount }) => {
|
|
11
|
+
export const Header = ({ pendingCount, updateInfo, onUpdateBadgeActivate }) => {
|
|
11
12
|
const { theme } = useTheme();
|
|
12
13
|
const [flash, setFlash] = useState(false);
|
|
13
14
|
const [prevCount, setPrevCount] = useState(pendingCount);
|
|
@@ -35,6 +36,7 @@ export const Header = ({ pendingCount }) => {
|
|
|
35
36
|
React.createElement(Text, { dimColor: true },
|
|
36
37
|
"v",
|
|
37
38
|
version),
|
|
39
|
+
updateInfo && (React.createElement(UpdateBadge, { updateType: updateInfo.updateType, latestVersion: updateInfo.latestVersion })),
|
|
38
40
|
React.createElement(Text, { dimColor: true }, " "),
|
|
39
41
|
React.createElement(Text, { backgroundColor: theme.components.header.pillBg, bold: flash, color: flash
|
|
40
42
|
? theme.components.header.queueFlash
|
|
@@ -23,9 +23,11 @@ function hasAnswers(answers) {
|
|
|
23
23
|
* SessionDots — a compact row of numbered dots rendered below the footer.
|
|
24
24
|
*
|
|
25
25
|
* Visual language:
|
|
26
|
-
* ● 1 ○ 2 ○
|
|
26
|
+
* ● 1 ○ 2 ✕ 3 ○ 4
|
|
27
27
|
*
|
|
28
28
|
* • Active session: filled ● + bold number in theme primary
|
|
29
|
+
* • Abandoned: red ✕ + "(AI disconnected)" when active
|
|
30
|
+
* • Stale: yellow ○ + "(stale)" when active
|
|
29
31
|
* • Has answers: green (theme.success)
|
|
30
32
|
* • Touched/no answers: yellow (theme.warning)
|
|
31
33
|
* • Untouched: dim (theme.textDim)
|
|
@@ -38,9 +40,22 @@ export const SessionDots = ({ sessions, activeIndex, sessionUIStates, }) => {
|
|
|
38
40
|
return (React.createElement(Box, { justifyContent: "center", paddingX: 1 }, sessions.map((session, idx) => {
|
|
39
41
|
const isActive = idx === activeIndex;
|
|
40
42
|
const uiState = sessionUIStates[session.sessionId];
|
|
43
|
+
const isStale = session.isStale ?? false;
|
|
44
|
+
const isAbandoned = session.isAbandoned ?? false;
|
|
41
45
|
// Determine the progress color for this session's dot
|
|
46
|
+
// Abandoned/stale take priority over normal state colors
|
|
42
47
|
let dotColor;
|
|
43
|
-
if (
|
|
48
|
+
if (isAbandoned) {
|
|
49
|
+
dotColor =
|
|
50
|
+
theme.components.sessionDots
|
|
51
|
+
.abandoned ?? theme.colors.error;
|
|
52
|
+
}
|
|
53
|
+
else if (isStale) {
|
|
54
|
+
dotColor =
|
|
55
|
+
theme.components.sessionDots
|
|
56
|
+
.stale ?? theme.colors.warning;
|
|
57
|
+
}
|
|
58
|
+
else if (isActive) {
|
|
44
59
|
dotColor = theme.components.sessionDots.active;
|
|
45
60
|
}
|
|
46
61
|
else if (uiState && hasAnswers(uiState.answers)) {
|
|
@@ -52,14 +67,28 @@ export const SessionDots = ({ sessions, activeIndex, sessionUIStates, }) => {
|
|
|
52
67
|
else {
|
|
53
68
|
dotColor = theme.components.sessionDots.untouched;
|
|
54
69
|
}
|
|
55
|
-
|
|
70
|
+
// Abandoned inactive sessions use ✕ to signal a problem
|
|
71
|
+
const dot = isAbandoned && !isActive
|
|
72
|
+
? "✕"
|
|
73
|
+
: isActive
|
|
74
|
+
? "●"
|
|
75
|
+
: "○";
|
|
56
76
|
const numberColor = isActive
|
|
57
77
|
? theme.components.sessionDots.activeNumber
|
|
58
78
|
: theme.components.sessionDots.number;
|
|
79
|
+
// Status label shown next to active abandoned/stale sessions
|
|
80
|
+
const statusLabel = isActive && isAbandoned
|
|
81
|
+
? "(AI disconnected)"
|
|
82
|
+
: isActive && isStale
|
|
83
|
+
? "(stale)"
|
|
84
|
+
: null;
|
|
59
85
|
return (React.createElement(Box, { key: session.sessionId, paddingRight: idx < sessions.length - 1 ? 1 : 0 },
|
|
60
86
|
React.createElement(Text, { color: dotColor, bold: isActive }, dot),
|
|
61
87
|
React.createElement(Text, { color: numberColor, bold: isActive },
|
|
62
88
|
" ",
|
|
63
|
-
idx + 1)
|
|
89
|
+
idx + 1),
|
|
90
|
+
statusLabel && (React.createElement(Text, { color: dotColor, dimColor: true },
|
|
91
|
+
" ",
|
|
92
|
+
statusLabel))));
|
|
64
93
|
})));
|
|
65
94
|
};
|
|
@@ -135,23 +135,31 @@ export const SessionPicker = ({ isOpen, sessions, activeIndex, sessionUIStates,
|
|
|
135
135
|
: answered > 0
|
|
136
136
|
? theme.components.sessionPicker.progress
|
|
137
137
|
: theme.components.sessionPicker.rowDim;
|
|
138
|
-
return (React.createElement(Box, { key: session.sessionId },
|
|
139
|
-
React.createElement(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
138
|
+
return (React.createElement(Box, { key: session.sessionId, flexDirection: "column" },
|
|
139
|
+
React.createElement(Box, null,
|
|
140
|
+
(session.isStale || session.isAbandoned) && (React.createElement(Text, { backgroundColor: rowBg, color: theme.components.sessionPicker.staleIcon },
|
|
141
|
+
"\u26A0",
|
|
142
|
+
" ")),
|
|
143
|
+
React.createElement(Text, { backgroundColor: rowBg, bold: isHighlighted, color: session.isStale || session.isAbandoned ? theme.components.sessionPicker.staleText : textColor },
|
|
144
|
+
isActive ? "►" : " ",
|
|
145
|
+
" ",
|
|
146
|
+
realIdx + 1,
|
|
147
|
+
". ",
|
|
148
|
+
title),
|
|
149
|
+
React.createElement(Text, { backgroundColor: rowBg, color: theme.components.sessionPicker.rowDim },
|
|
150
|
+
" — ",
|
|
151
|
+
dir),
|
|
152
|
+
React.createElement(Text, { backgroundColor: rowBg, color: progressColor },
|
|
153
|
+
" [",
|
|
154
|
+
answered,
|
|
155
|
+
"/",
|
|
156
|
+
total,
|
|
157
|
+
"]"),
|
|
158
|
+
React.createElement(Text, { backgroundColor: rowBg, color: session.isStale || session.isAbandoned ? theme.components.sessionPicker.staleAge : theme.components.sessionPicker.rowDim, dimColor: !(session.isStale || session.isAbandoned) }, age)),
|
|
159
|
+
session.isStale && !session.isAbandoned && (React.createElement(Box, { marginLeft: session.isStale ? 4 : 2 },
|
|
160
|
+
React.createElement(Text, { color: theme.components.sessionPicker.staleSubtitle, dimColor: true }, "may be orphaned"))),
|
|
161
|
+
session.isAbandoned && (React.createElement(Box, { marginLeft: 4 },
|
|
162
|
+
React.createElement(Text, { color: theme.components.sessionPicker.staleSubtitle, bold: true }, "session abandoned")))));
|
|
155
163
|
}),
|
|
156
164
|
needsScroll && scrollOffset + maxVisibleRows < sessions.length && (React.createElement(Box, { justifyContent: "center" },
|
|
157
165
|
React.createElement(Text, { color: theme.components.sessionPicker.rowDim }, "\u25BC more"))),
|