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.
Files changed (71) hide show
  1. package/README.md +122 -0
  2. package/dist/bin/auq.js +87 -93
  3. package/dist/bin/tui-app.js +183 -7
  4. package/dist/package.json +1 -1
  5. package/dist/src/__tests__/server.abort.test.js +214 -0
  6. package/dist/src/cli/commands/__tests__/answer.test.js +199 -0
  7. package/dist/src/cli/commands/__tests__/config.test.js +218 -0
  8. package/dist/src/cli/commands/__tests__/sessions.test.js +282 -0
  9. package/dist/src/cli/commands/answer.js +128 -0
  10. package/dist/src/cli/commands/config.js +263 -0
  11. package/dist/src/cli/commands/sessions.js +300 -0
  12. package/dist/src/cli/commands/update.js +124 -0
  13. package/dist/src/cli/utils.js +95 -0
  14. package/dist/src/config/__tests__/ConfigLoader.test.js +41 -0
  15. package/dist/src/config/__tests__/updateCheck.test.js +34 -0
  16. package/dist/src/config/defaults.js +5 -0
  17. package/dist/src/config/types.js +6 -0
  18. package/dist/src/core/ask-user-questions.js +3 -2
  19. package/dist/src/i18n/locales/en.js +7 -0
  20. package/dist/src/i18n/locales/ko.js +7 -0
  21. package/dist/src/server.js +64 -11
  22. package/dist/src/session/SessionManager.js +69 -4
  23. package/dist/src/session/__tests__/SessionManager.test.js +65 -0
  24. package/dist/src/tui/__tests__/session-watcher.test.js +109 -0
  25. package/dist/src/tui/components/Footer.js +4 -1
  26. package/dist/src/tui/components/Header.js +3 -1
  27. package/dist/src/tui/components/SessionDots.js +33 -4
  28. package/dist/src/tui/components/SessionPicker.js +25 -17
  29. package/dist/src/tui/components/StepperView.js +68 -5
  30. package/dist/src/tui/components/UpdateBadge.js +29 -0
  31. package/dist/src/tui/components/UpdateOverlay.js +199 -0
  32. package/dist/src/tui/components/__tests__/SessionDots.test.js +160 -1
  33. package/dist/src/tui/components/__tests__/SessionPicker.test.js +43 -1
  34. package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +160 -0
  35. package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -0
  36. package/dist/src/tui/constants/keybindings.js +3 -0
  37. package/dist/src/tui/session-watcher.js +50 -0
  38. package/dist/src/tui/themes/catppuccin-latte.js +7 -0
  39. package/dist/src/tui/themes/catppuccin-mocha.js +7 -0
  40. package/dist/src/tui/themes/dark.js +7 -0
  41. package/dist/src/tui/themes/dracula.js +7 -0
  42. package/dist/src/tui/themes/github-dark.js +7 -0
  43. package/dist/src/tui/themes/github-light.js +7 -0
  44. package/dist/src/tui/themes/gruvbox-dark.js +7 -0
  45. package/dist/src/tui/themes/gruvbox-light.js +7 -0
  46. package/dist/src/tui/themes/light.js +7 -0
  47. package/dist/src/tui/themes/monokai.js +7 -0
  48. package/dist/src/tui/themes/nord.js +7 -0
  49. package/dist/src/tui/themes/one-dark.js +7 -0
  50. package/dist/src/tui/themes/rose-pine.js +7 -0
  51. package/dist/src/tui/themes/solarized-dark.js +7 -0
  52. package/dist/src/tui/themes/solarized-light.js +7 -0
  53. package/dist/src/tui/themes/tokyo-night.js +7 -0
  54. package/dist/src/tui/utils/__tests__/staleDetection.test.js +118 -0
  55. package/dist/src/tui/utils/staleDetection.js +51 -0
  56. package/dist/src/update/__tests__/cache.test.js +136 -0
  57. package/dist/src/update/__tests__/changelog.test.js +86 -0
  58. package/dist/src/update/__tests__/checker.test.js +148 -0
  59. package/dist/src/update/__tests__/index.test.js +37 -0
  60. package/dist/src/update/__tests__/installer.test.js +117 -0
  61. package/dist/src/update/__tests__/package-manager.test.js +73 -0
  62. package/dist/src/update/__tests__/version.test.js +74 -0
  63. package/dist/src/update/cache.js +74 -0
  64. package/dist/src/update/changelog.js +63 -0
  65. package/dist/src/update/checker.js +121 -0
  66. package/dist/src/update/index.js +15 -0
  67. package/dist/src/update/installer.js +51 -0
  68. package/dist/src/update/package-manager.js +49 -0
  69. package/dist/src/update/types.js +7 -0
  70. package/dist/src/update/version.js +114 -0
  71. 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
  };
@@ -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
- const { formattedResponse, sessionId } = await askUserQuestionsCore.ask(args.questions, callId, workingDirectory);
50
- log.info("Session completed successfully", { sessionId, callId });
51
- // Return formatted response to AI model
52
- return {
53
- content: [
54
- {
55
- text: formattedResponse,
56
- type: "text",
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 ○ 3
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 (isActive) {
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
- const dot = isActive ? "●" : "○";
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(Text, { backgroundColor: rowBg, bold: isHighlighted, color: textColor },
140
- isActive ? "►" : " ",
141
- " ",
142
- realIdx + 1,
143
- ". ",
144
- title),
145
- React.createElement(Text, { backgroundColor: rowBg, color: theme.components.sessionPicker.rowDim },
146
- " ",
147
- dir),
148
- React.createElement(Text, { backgroundColor: rowBg, color: progressColor },
149
- " [",
150
- answered,
151
- "/",
152
- total,
153
- "]"),
154
- React.createElement(Text, { backgroundColor: rowBg, color: theme.components.sessionPicker.rowDim, dimColor: true }, age)));
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"))),