auq-mcp-server 2.3.0 → 2.4.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 (51) hide show
  1. package/README.md +82 -0
  2. package/dist/bin/auq.js +47 -93
  3. package/dist/bin/tui-app.js +69 -6
  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 +164 -0
  12. package/dist/src/cli/utils.js +95 -0
  13. package/dist/src/config/__tests__/ConfigLoader.test.js +41 -0
  14. package/dist/src/config/defaults.js +3 -0
  15. package/dist/src/config/types.js +4 -0
  16. package/dist/src/core/ask-user-questions.js +3 -2
  17. package/dist/src/i18n/locales/en.js +7 -0
  18. package/dist/src/i18n/locales/ko.js +7 -0
  19. package/dist/src/server.js +64 -11
  20. package/dist/src/session/SessionManager.js +69 -4
  21. package/dist/src/session/__tests__/SessionManager.test.js +65 -0
  22. package/dist/src/tui/__tests__/session-watcher.test.js +109 -0
  23. package/dist/src/tui/components/SessionDots.js +33 -4
  24. package/dist/src/tui/components/SessionPicker.js +25 -17
  25. package/dist/src/tui/components/Spinner.js +19 -0
  26. package/dist/src/tui/components/StepperView.js +68 -5
  27. package/dist/src/tui/components/__tests__/SessionDots.test.js +160 -1
  28. package/dist/src/tui/components/__tests__/SessionPicker.test.js +43 -1
  29. package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +160 -0
  30. package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -0
  31. package/dist/src/tui/session-watcher.js +50 -0
  32. package/dist/src/tui/themes/catppuccin-latte.js +7 -0
  33. package/dist/src/tui/themes/catppuccin-mocha.js +7 -0
  34. package/dist/src/tui/themes/dark.js +7 -0
  35. package/dist/src/tui/themes/dracula.js +7 -0
  36. package/dist/src/tui/themes/github-dark.js +7 -0
  37. package/dist/src/tui/themes/github-light.js +7 -0
  38. package/dist/src/tui/themes/gruvbox-dark.js +7 -0
  39. package/dist/src/tui/themes/gruvbox-light.js +7 -0
  40. package/dist/src/tui/themes/light.js +7 -0
  41. package/dist/src/tui/themes/monokai.js +7 -0
  42. package/dist/src/tui/themes/nord.js +7 -0
  43. package/dist/src/tui/themes/one-dark.js +7 -0
  44. package/dist/src/tui/themes/rose-pine.js +7 -0
  45. package/dist/src/tui/themes/solarized-dark.js +7 -0
  46. package/dist/src/tui/themes/solarized-light.js +7 -0
  47. package/dist/src/tui/themes/tokyo-night.js +7 -0
  48. package/dist/src/tui/utils/__tests__/detectTheme.test.js +78 -0
  49. package/dist/src/tui/utils/__tests__/staleDetection.test.js +118 -0
  50. package/dist/src/tui/utils/staleDetection.js +51 -0
  51. package/package.json +1 -1
@@ -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", () => {
@@ -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"))),
@@ -0,0 +1,19 @@
1
+ import { Text } from "ink";
2
+ import React, { useEffect, useState } from "react";
3
+ import { useTheme } from "../ThemeContext.js";
4
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5
+ /**
6
+ * Spinner displays an animated loading indicator
7
+ * Uses braille pattern characters for smooth animation
8
+ */
9
+ export const Spinner = ({ color }) => {
10
+ const { theme } = useTheme();
11
+ const [frame, setFrame] = useState(0);
12
+ useEffect(() => {
13
+ const timer = setInterval(() => {
14
+ setFrame((prev) => (prev + 1) % SPINNER_FRAMES.length);
15
+ }, 80);
16
+ return () => clearInterval(timer);
17
+ }, []);
18
+ return (React.createElement(Text, { color: color ?? theme.colors.primary }, SPINNER_FRAMES[frame]));
19
+ };
@@ -1,4 +1,4 @@
1
- import { Box, useApp, useInput, useStdout } from "ink";
1
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
2
2
  import React, { useEffect, useMemo, useRef, useState } from "react";
3
3
  import { t } from "../../i18n/index.js";
4
4
  import { ResponseFormatter } from "../../session/ResponseFormatter.js";
@@ -15,7 +15,7 @@ import { ReviewScreen } from "./ReviewScreen.js";
15
15
  * StepperView orchestrates the question-answering flow
16
16
  * Manages state for current question, answers, and navigation
17
17
  */
18
- export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initialState, onStateSnapshot, onFlowStateChange, sessionId, sessionRequest, }) => {
18
+ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initialState, onStateSnapshot, onFlowStateChange, sessionId, sessionRequest, isAbandoned, onAbandonedCancel, }) => {
19
19
  const { theme } = useTheme();
20
20
  const config = useConfig();
21
21
  const { exit } = useApp();
@@ -24,6 +24,9 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
24
24
  const [showReview, setShowReview] = useState(false);
25
25
  const [submitting, setSubmitting] = useState(false);
26
26
  const [showRejectionConfirm, setShowRejectionConfirm] = useState(false);
27
+ const [showAbandonedConfirm, setShowAbandonedConfirm] = useState(false);
28
+ const [abandonedConfirmed, setAbandonedConfirmed] = useState(false);
29
+ const [abandonedFocusedIndex, setAbandonedFocusedIndex] = useState(0);
27
30
  const [elapsedSeconds, setElapsedSeconds] = useState(0);
28
31
  const [focusContext, setFocusContext] = useState("option");
29
32
  const [focusedOptionIndex, setFocusedOptionIndex] = useState(0);
@@ -180,6 +183,16 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
180
183
  const anyHasRecommended = sessionRequest.questions.some((question) => question.options.some((opt) => isRecommendedOption(opt.label)));
181
184
  setHasAnyRecommendedInSession(anyHasRecommended);
182
185
  }, [initialState, sessionId, sessionRequest.questions]);
186
+ // Show abandoned confirmation when entering an abandoned session
187
+ useEffect(() => {
188
+ if (isAbandoned && !abandonedConfirmed) {
189
+ setShowAbandonedConfirm(true);
190
+ setAbandonedFocusedIndex(0);
191
+ }
192
+ else {
193
+ setShowAbandonedConfirm(false);
194
+ }
195
+ }, [sessionId, isAbandoned, abandonedConfirmed]);
183
196
  useEffect(() => {
184
197
  if (!onStateSnapshot) {
185
198
  return;
@@ -207,8 +220,8 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
207
220
  showReview,
208
221
  ]);
209
222
  useEffect(() => {
210
- onFlowStateChange?.({ showReview, showRejectionConfirm });
211
- }, [onFlowStateChange, showRejectionConfirm, showReview]);
223
+ onFlowStateChange?.({ showReview, showRejectionConfirm, showAbandonedConfirm });
224
+ }, [onFlowStateChange, showRejectionConfirm, showReview, showAbandonedConfirm]);
212
225
  // Update elapsed time since session creation
213
226
  // IMPORTANT: Pause when content overflows terminal to prevent scroll-snapping
214
227
  useEffect(() => {
@@ -343,10 +356,35 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
343
356
  return newMarks;
344
357
  });
345
358
  };
359
+ // Keyboard handling for abandoned confirmation dialog
360
+ useInput((input, key) => {
361
+ if (!showAbandonedConfirm)
362
+ return;
363
+ if (key.upArrow) {
364
+ setAbandonedFocusedIndex((prev) => Math.max(0, prev - 1));
365
+ }
366
+ if (key.downArrow) {
367
+ setAbandonedFocusedIndex((prev) => Math.min(1, prev + 1));
368
+ }
369
+ if (key.return) {
370
+ if (abandonedFocusedIndex === 0) {
371
+ // "Answer anyway"
372
+ setAbandonedConfirmed(true);
373
+ setShowAbandonedConfirm(false);
374
+ }
375
+ else {
376
+ // "Cancel"
377
+ onAbandonedCancel?.();
378
+ }
379
+ }
380
+ if (key.escape) {
381
+ onAbandonedCancel?.();
382
+ }
383
+ });
346
384
  // Global keyboard shortcuts and navigation
347
385
  useInput((input, key) => {
348
386
  // Don't handle navigation when showing review, submitting, or confirming rejection
349
- if (showReview || submitting || showRejectionConfirm)
387
+ if (showReview || submitting || showRejectionConfirm || showAbandonedConfirm)
350
388
  return;
351
389
  // Derive text-input state from both focusContext and focusedOptionIndex
352
390
  // focusContext may lag by one render cycle (set via useEffect in OptionsList)
@@ -481,6 +519,31 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
481
519
  }
482
520
  }
483
521
  };
522
+ // Show abandoned session confirmation
523
+ if (showAbandonedConfirm) {
524
+ const abandonedOptions = [
525
+ { label: t("abandoned.continue"), action: () => { setAbandonedConfirmed(true); setShowAbandonedConfirm(false); } },
526
+ { label: t("abandoned.cancel"), action: () => { onAbandonedCancel?.(); } },
527
+ ];
528
+ return (React.createElement(Box, { flexDirection: "column", padding: 1 },
529
+ React.createElement(Box, { borderColor: theme.borders.warning, borderStyle: "round", flexDirection: "column", padding: 1 },
530
+ React.createElement(Box, { marginBottom: 1 },
531
+ React.createElement(Text, { bold: true, color: theme.colors.warning }, t("abandoned.title"))),
532
+ React.createElement(Box, { marginBottom: 1 },
533
+ React.createElement(Text, null, t("abandoned.message"))),
534
+ abandonedOptions.map((option, index) => {
535
+ const isFocused = index === abandonedFocusedIndex;
536
+ const rowBg = isFocused
537
+ ? theme.components.options.focusedBg
538
+ : undefined;
539
+ return (React.createElement(Box, { key: index, marginTop: index > 0 ? 0.5 : 0 },
540
+ React.createElement(Text, { backgroundColor: rowBg, bold: isFocused, color: isFocused ? theme.colors.focused : theme.colors.text },
541
+ isFocused ? "> " : " ",
542
+ option.label)));
543
+ }),
544
+ React.createElement(Box, { marginTop: 1 },
545
+ React.createElement(Text, { dimColor: true }, "↑↓ Navigate | Enter Select")))));
546
+ }
484
547
  // Show rejection confirmation
485
548
  if (showRejectionConfirm) {
486
549
  return (React.createElement(Box, { flexDirection: "column", padding: 1 },
@@ -15,7 +15,7 @@ function renderWithTheme(ui) {
15
15
  function getOutput(frame) {
16
16
  return (frame ?? "").replace(/\x1b\[[0-9;]*m/g, "").replace(/\r/g, "");
17
17
  }
18
- function createSession(id) {
18
+ function createSession(id, overrides) {
19
19
  return {
20
20
  sessionId: `test-id-${id}`,
21
21
  sessionRequest: {
@@ -33,6 +33,7 @@ function createSession(id) {
33
33
  ],
34
34
  },
35
35
  timestamp: new Date("2026-01-01T00:00:00.000Z"),
36
+ ...overrides,
36
37
  };
37
38
  }
38
39
  afterEach(() => {
@@ -89,4 +90,162 @@ describe("SessionDots", () => {
89
90
  expect(output).toContain("3");
90
91
  expect(output).toContain("4");
91
92
  });
93
+ describe("abandoned sessions", () => {
94
+ it("renders abandoned session with ✕ symbol when inactive", () => {
95
+ const sessions = [
96
+ createSession(1),
97
+ createSession(2, { isAbandoned: true }),
98
+ createSession(3),
99
+ ];
100
+ const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 0, sessionUIStates: {} }));
101
+ const output = getOutput(instance.lastFrame());
102
+ // Abandoned inactive session uses ✕ instead of ○
103
+ expect(output).toContain("✕");
104
+ // Active session still uses ●
105
+ expect(output).toContain("●");
106
+ // Non-abandoned inactive session still uses ○
107
+ expect(output).toContain("○");
108
+ });
109
+ it("renders abandoned session with different ANSI styling than normal", () => {
110
+ // Render with abandoned session
111
+ const abandonedSessions = [
112
+ createSession(1),
113
+ createSession(2, { isAbandoned: true }),
114
+ ];
115
+ const abandoned = renderWithTheme(React.createElement(SessionDots, { sessions: abandonedSessions, activeIndex: 0, sessionUIStates: {} }));
116
+ const abandonedRaw = abandoned.lastFrame() ?? "";
117
+ // Render with normal session
118
+ const normalSessions = [createSession(1), createSession(2)];
119
+ const normal = renderWithTheme(React.createElement(SessionDots, { sessions: normalSessions, activeIndex: 0, sessionUIStates: {} }));
120
+ const normalRaw = normal.lastFrame() ?? "";
121
+ // Abandoned session should render differently from normal
122
+ // (different ANSI codes due to error color)
123
+ expect(abandonedRaw).not.toBe(normalRaw);
124
+ });
125
+ it('shows "(AI disconnected)" text when active session is abandoned', () => {
126
+ const sessions = [
127
+ createSession(1),
128
+ createSession(2, { isAbandoned: true }),
129
+ ];
130
+ const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 1, sessionUIStates: {} }));
131
+ const output = getOutput(instance.lastFrame());
132
+ expect(output).toContain("(AI disconnected)");
133
+ });
134
+ it('does NOT show "(AI disconnected)" when abandoned session is inactive', () => {
135
+ const sessions = [
136
+ createSession(1),
137
+ createSession(2, { isAbandoned: true }),
138
+ ];
139
+ const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 0, sessionUIStates: {} }));
140
+ const output = getOutput(instance.lastFrame());
141
+ expect(output).not.toContain("(AI disconnected)");
142
+ });
143
+ it("uses ● for active abandoned session (not ✕)", () => {
144
+ const sessions = [
145
+ createSession(1),
146
+ createSession(2, { isAbandoned: true }),
147
+ ];
148
+ const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 1, sessionUIStates: {} }));
149
+ const output = getOutput(instance.lastFrame());
150
+ // Active abandoned session should still use ● (filled dot), not ✕
151
+ expect(output).toContain("●");
152
+ });
153
+ });
154
+ describe("stale sessions", () => {
155
+ it("renders stale session with ○ symbol (unchanged from normal)", () => {
156
+ const sessions = [
157
+ createSession(1),
158
+ createSession(2, { isStale: true }),
159
+ createSession(3),
160
+ ];
161
+ const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 0, sessionUIStates: {} }));
162
+ const output = getOutput(instance.lastFrame());
163
+ // Stale sessions keep ○ but with yellow color
164
+ // Count: 1 active ●, 2 inactive ○ (one stale, one normal)
165
+ expect((output.match(/○/g) ?? []).length).toBe(2);
166
+ });
167
+ it("applies stale color when session is stale (color differs from untouched)", () => {
168
+ // When a stale session is active, it gets the stale/warning color
169
+ // and shows a "(stale)" label — verifying the flag is correctly consumed.
170
+ // Since ink-testing-library may strip ANSI in some envs, we verify
171
+ // that stale active sessions show the label as a proxy for color.
172
+ const sessions = [
173
+ createSession(1),
174
+ createSession(2, { isStale: true }),
175
+ ];
176
+ const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 1, sessionUIStates: {} }));
177
+ const output = getOutput(instance.lastFrame());
178
+ // Active stale session should have the filled dot and stale label
179
+ expect(output).toContain("●");
180
+ expect(output).toContain("(stale)");
181
+ // Stale sessions don't use ✕ (that's only for abandoned)
182
+ expect(output).not.toContain("✕");
183
+ });
184
+ it('shows "(stale)" text when active session is stale', () => {
185
+ const sessions = [
186
+ createSession(1),
187
+ createSession(2, { isStale: true }),
188
+ ];
189
+ const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 1, sessionUIStates: {} }));
190
+ const output = getOutput(instance.lastFrame());
191
+ expect(output).toContain("(stale)");
192
+ });
193
+ it('does NOT show "(stale)" when stale session is inactive', () => {
194
+ const sessions = [
195
+ createSession(1),
196
+ createSession(2, { isStale: true }),
197
+ ];
198
+ const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 0, sessionUIStates: {} }));
199
+ const output = getOutput(instance.lastFrame());
200
+ expect(output).not.toContain("(stale)");
201
+ });
202
+ });
203
+ describe("mixed states", () => {
204
+ it("renders multiple sessions with mixed states correctly", () => {
205
+ const sessions = [
206
+ createSession(1), // normal (active)
207
+ createSession(2, { isAbandoned: true }), // abandoned
208
+ createSession(3, { isStale: true }), // stale
209
+ createSession(4), // normal
210
+ ];
211
+ const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 0, sessionUIStates: {} }));
212
+ const output = getOutput(instance.lastFrame());
213
+ // Active session shows ●
214
+ expect(output).toContain("●");
215
+ // Abandoned inactive shows ✕
216
+ expect(output).toContain("✕");
217
+ // Normal and stale inactive show ○
218
+ expect((output.match(/○/g) ?? []).length).toBe(2);
219
+ // All 4 session numbers rendered
220
+ expect(output).toContain("1");
221
+ expect(output).toContain("2");
222
+ expect(output).toContain("3");
223
+ expect(output).toContain("4");
224
+ });
225
+ it("abandoned takes priority over stale", () => {
226
+ const sessions = [
227
+ createSession(1),
228
+ createSession(2, { isAbandoned: true, isStale: true }),
229
+ ];
230
+ const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 1, sessionUIStates: {} }));
231
+ const output = getOutput(instance.lastFrame());
232
+ // When both abandoned and stale, show abandoned status
233
+ expect(output).toContain("(AI disconnected)");
234
+ expect(output).not.toContain("(stale)");
235
+ });
236
+ it("normal sessions remain unchanged (regression)", () => {
237
+ const sessions = [createSession(1), createSession(2), createSession(3)];
238
+ const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 1, sessionUIStates: {} }));
239
+ const output = getOutput(instance.lastFrame());
240
+ // No stale/abandoned indicators for normal sessions
241
+ expect(output).not.toContain("✕");
242
+ expect(output).not.toContain("(AI disconnected)");
243
+ expect(output).not.toContain("(stale)");
244
+ // Normal rendering still works
245
+ expect(output).toContain("●");
246
+ expect(output).toContain("○");
247
+ expect((output.match(/●/g) ?? []).length).toBe(1);
248
+ expect((output.match(/○/g) ?? []).length).toBe(2);
249
+ });
250
+ });
92
251
  });