auq-mcp-server 2.2.2 → 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 (62) hide show
  1. package/README.md +82 -0
  2. package/dist/bin/auq.js +45 -39
  3. package/dist/bin/tui-app.js +78 -8
  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 +8 -1
  18. package/dist/src/i18n/locales/ko.js +8 -1
  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/ThemeProvider.js +2 -1
  23. package/dist/src/tui/__tests__/session-watcher.test.js +109 -0
  24. package/dist/src/tui/components/ConfirmationDialog.js +5 -4
  25. package/dist/src/tui/components/Footer.js +24 -23
  26. package/dist/src/tui/components/ReviewScreen.js +2 -1
  27. package/dist/src/tui/components/SessionDots.js +33 -4
  28. package/dist/src/tui/components/SessionPicker.js +27 -18
  29. package/dist/src/tui/components/Spinner.js +19 -0
  30. package/dist/src/tui/components/StepperView.js +71 -7
  31. package/dist/src/tui/components/WaitingScreen.js +2 -1
  32. package/dist/src/tui/components/__tests__/ConfirmationDialog.test.js +134 -0
  33. package/dist/src/tui/components/__tests__/Footer.test.js +121 -0
  34. package/dist/src/tui/components/__tests__/ReviewScreen.test.js +89 -0
  35. package/dist/src/tui/components/__tests__/SessionDots.test.js +160 -1
  36. package/dist/src/tui/components/__tests__/SessionPicker.test.js +43 -1
  37. package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +160 -0
  38. package/dist/src/tui/components/__tests__/StepperView.keyboard.test.js +135 -0
  39. package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -0
  40. package/dist/src/tui/components/__tests__/WaitingScreen.test.js +60 -0
  41. package/dist/src/tui/constants/keybindings.js +40 -0
  42. package/dist/src/tui/session-watcher.js +50 -0
  43. package/dist/src/tui/themes/catppuccin-latte.js +7 -0
  44. package/dist/src/tui/themes/catppuccin-mocha.js +7 -0
  45. package/dist/src/tui/themes/dark.js +7 -0
  46. package/dist/src/tui/themes/dracula.js +7 -0
  47. package/dist/src/tui/themes/github-dark.js +7 -0
  48. package/dist/src/tui/themes/github-light.js +7 -0
  49. package/dist/src/tui/themes/gruvbox-dark.js +7 -0
  50. package/dist/src/tui/themes/gruvbox-light.js +7 -0
  51. package/dist/src/tui/themes/light.js +7 -0
  52. package/dist/src/tui/themes/monokai.js +7 -0
  53. package/dist/src/tui/themes/nord.js +7 -0
  54. package/dist/src/tui/themes/one-dark.js +7 -0
  55. package/dist/src/tui/themes/rose-pine.js +7 -0
  56. package/dist/src/tui/themes/solarized-dark.js +7 -0
  57. package/dist/src/tui/themes/solarized-light.js +7 -0
  58. package/dist/src/tui/themes/tokyo-night.js +7 -0
  59. package/dist/src/tui/utils/__tests__/detectTheme.test.js +78 -0
  60. package/dist/src/tui/utils/__tests__/staleDetection.test.js +118 -0
  61. package/dist/src/tui/utils/staleDetection.js +51 -0
  62. 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
  });
@@ -4,6 +4,7 @@ import { ThemeContext } from "./ThemeContext.js";
4
4
  import { getTheme, listThemes, darkTheme, hasTheme } from "./themes/index.js";
5
5
  import { detectSystemTheme } from "./utils/detectTheme.js";
6
6
  import { getSavedTheme, saveTheme } from "./utils/config.js";
7
+ import { KEYS } from "./constants/keybindings.js";
7
8
  function resolveTheme(mode) {
8
9
  if (mode === "system") {
9
10
  const detected = detectSystemTheme();
@@ -42,7 +43,7 @@ export const ThemeProvider = ({ initialTheme, children, }) => {
42
43
  }, []);
43
44
  // Ctrl+T to cycle theme
44
45
  useInput((input, key) => {
45
- if (key.ctrl && input === "t") {
46
+ if (key.ctrl && input === KEYS.THEME_CYCLE) {
46
47
  cycleTheme();
47
48
  }
48
49
  });
@@ -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", () => {
@@ -3,6 +3,7 @@ import React, { useState } from "react";
3
3
  import { useTheme } from "../ThemeContext.js";
4
4
  import { SingleLineTextInput } from "./SingleLineTextInput.js";
5
5
  import { t } from "../../i18n/index.js";
6
+ import { KEYS } from "../constants/keybindings.js";
6
7
  /**
7
8
  * ConfirmationDialog shows a 3-option prompt for session rejection
8
9
  * Options: Reject & inform AI, Cancel, or Quit CLI
@@ -37,20 +38,20 @@ export const ConfirmationDialog = ({ message, onReject, onCancel, onQuit, }) =>
37
38
  }
38
39
  // Arrow key navigation
39
40
  if (key.upArrow) {
40
- setFocusedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1));
41
+ setFocusedIndex((prev) => Math.max(0, prev - 1));
41
42
  }
42
43
  if (key.downArrow) {
43
- setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0));
44
+ setFocusedIndex((prev) => Math.min(options.length - 1, prev + 1));
44
45
  }
45
46
  // Enter key - select focused option
46
47
  if (key.return) {
47
48
  options[focusedIndex].action();
48
49
  }
49
50
  // Letter shortcuts
50
- if (input === "y" || input === "Y") {
51
+ if (KEYS.CONFIRM_YES.test(input)) {
51
52
  setShowReasonInput(true);
52
53
  }
53
- if (input === "n" || input === "N") {
54
+ if (KEYS.CONFIRM_NO.test(input)) {
54
55
  onCancel();
55
56
  }
56
57
  // Esc key - same as quit
@@ -2,6 +2,7 @@ import { Box, Text } from "ink";
2
2
  import React, { useEffect, useState } from "react";
3
3
  import { t } from "../../i18n/index.js";
4
4
  import { useTheme } from "../ThemeContext.js";
5
+ import { KEY_LABELS } from "../constants/keybindings.js";
5
6
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
7
  /**
7
8
  * Footer component - displays context-aware keybindings
@@ -23,58 +24,58 @@ export const Footer = ({ focusContext, multiSelect, isReviewScreen = false, show
23
24
  // Review screen mode
24
25
  if (isReviewScreen) {
25
26
  return [
26
- { key: "Enter", action: t("footer.submit") },
27
- { key: "n", action: t("footer.back") },
27
+ { key: KEY_LABELS.SUBMIT, action: t("footer.submit") },
28
+ { key: KEY_LABELS.BACK, action: t("footer.back") },
28
29
  ];
29
30
  }
30
31
  // Custom input focused
31
32
  if (focusContext === "custom-input") {
32
33
  return [
33
- { key: "↑↓", action: t("footer.options") },
34
- { key: "←→", action: t("footer.cursor") },
35
- { key: "Tab/S+Tab", action: t("footer.questions") },
36
- { key: "Enter", action: t("footer.newline") },
37
- { key: "Esc", action: t("footer.reject") },
34
+ { key: KEY_LABELS.NAVIGATE_OPTIONS, action: t("footer.options") },
35
+ { key: KEY_LABELS.CURSOR, action: t("footer.cursor") },
36
+ { key: KEY_LABELS.NAVIGATE_QUESTIONS_TAB, action: t("footer.questions") },
37
+ { key: KEY_LABELS.NEWLINE, action: t("footer.newline") },
38
+ { key: KEY_LABELS.REJECT, action: t("footer.reject") },
38
39
  ];
39
40
  }
40
41
  // Elaborate input focused (Enter skips, not newline)
41
42
  if (focusContext === "elaborate-input") {
42
43
  return [
43
- { key: "↑↓", action: t("footer.options") },
44
- { key: "←→", action: t("footer.cursor") },
44
+ { key: KEY_LABELS.NAVIGATE_OPTIONS, action: t("footer.options") },
45
+ { key: KEY_LABELS.CURSOR, action: t("footer.cursor") },
45
46
  { key: "Enter/Tab", action: t("footer.next") },
46
- { key: "Esc", action: t("footer.reject") },
47
+ { key: KEY_LABELS.REJECT, action: t("footer.reject") },
47
48
  ];
48
49
  }
49
50
  // Option focused
50
51
  if (focusContext === "option") {
51
52
  const bindings = [
52
- { key: "↑↓", action: t("footer.options") },
53
- { key: "←→", action: t("footer.questions") },
54
- { key: "Tab/S+Tab", action: t("footer.questions") },
53
+ { key: KEY_LABELS.NAVIGATE_OPTIONS, action: t("footer.options") },
54
+ { key: KEY_LABELS.NAVIGATE_QUESTIONS, action: t("footer.questions") },
55
+ { key: KEY_LABELS.NAVIGATE_QUESTIONS_TAB, action: t("footer.questions") },
55
56
  ];
56
57
  if (multiSelect) {
57
- bindings.push({ key: "Space", action: t("footer.toggle") });
58
- bindings.push({ key: "Enter", action: t("footer.next") });
58
+ bindings.push({ key: KEY_LABELS.SELECT, action: t("footer.toggle") });
59
+ bindings.push({ key: KEY_LABELS.NEXT, action: t("footer.next") });
59
60
  }
60
61
  else {
61
- bindings.push({ key: "Space", action: t("footer.select") });
62
- bindings.push({ key: "Enter", action: t("footer.selectNext") });
62
+ bindings.push({ key: KEY_LABELS.SELECT, action: t("footer.select") });
63
+ bindings.push({ key: KEY_LABELS.SELECT_NEXT, action: t("footer.selectNext") });
63
64
  }
64
65
  if (hasRecommendedOptions) {
65
- bindings.push({ key: "R", action: t("footer.recommended") });
66
+ bindings.push({ key: KEY_LABELS.RECOMMEND, action: t("footer.recommended") });
66
67
  }
67
68
  // Ctrl+R shows when ANY question in session has recommended (not just current)
68
69
  if (hasAnyRecommendedInSession) {
69
- bindings.push({ key: "Ctrl+R", action: t("footer.quickSubmit") });
70
+ bindings.push({ key: KEY_LABELS.QUICK_SUBMIT, action: t("footer.quickSubmit") });
70
71
  }
71
72
  if (showSessionSwitching) {
72
- bindings.push({ key: "Ctrl+]/[", action: t("footer.sessions") });
73
+ bindings.push({ key: KEY_LABELS.SESSION_SWITCH, action: t("footer.sessions") });
73
74
  bindings.push({ key: "1-9", action: t("footer.jump") });
74
- bindings.push({ key: "Ctrl+S", action: t("footer.list") });
75
+ bindings.push({ key: KEY_LABELS.SESSION_LIST, action: t("footer.list") });
75
76
  }
76
- bindings.push({ key: "Ctrl+T", action: t("footer.theme") });
77
- bindings.push({ key: "Esc", action: t("footer.reject") });
77
+ bindings.push({ key: KEY_LABELS.THEME, action: t("footer.theme") });
78
+ bindings.push({ key: KEY_LABELS.REJECT, action: t("footer.reject") });
78
79
  return bindings;
79
80
  }
80
81
  return [];
@@ -4,6 +4,7 @@ import { t } from "../../i18n/index.js";
4
4
  import { useTheme } from "../ThemeContext.js";
5
5
  import { Footer } from "./Footer.js";
6
6
  import { MarkdownPrompt } from "./MarkdownPrompt.js";
7
+ import { KEYS } from "../constants/keybindings.js";
7
8
  /**
8
9
  * ReviewScreen displays a summary of all answers for confirmation
9
10
  * User can press Enter to confirm and submit, or 'n' to go back and edit
@@ -32,7 +33,7 @@ export const ReviewScreen = ({ answers, elapsedLabel, onConfirm, onGoBack, quest
32
33
  });
33
34
  onConfirm(userAnswers);
34
35
  }
35
- if (input === "n") {
36
+ if (KEYS.GO_BACK.test(input)) {
36
37
  onGoBack();
37
38
  }
38
39
  });
@@ -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
  };
@@ -2,6 +2,7 @@ import { Box, Text, useInput, useStdout } from "ink";
2
2
  import React, { useEffect, useState } from "react";
3
3
  import { useTheme } from "../ThemeContext.js";
4
4
  import { formatRelativeTime } from "../utils/relativeTime.js";
5
+ import { KEYS } from "../constants/keybindings.js";
5
6
  /* ------------------------------------------------------------------ */
6
7
  /* Helpers */
7
8
  /* ------------------------------------------------------------------ */
@@ -69,7 +70,7 @@ export const SessionPicker = ({ isOpen, sessions, activeIndex, sessionUIStates,
69
70
  else {
70
71
  // Direct number jump (1-9)
71
72
  const num = parseInt(input, 10);
72
- if (num >= 1 && num <= sessions.length) {
73
+ if (num >= KEYS.SESSION_JUMP_MIN && num <= Math.min(KEYS.SESSION_JUMP_MAX, sessions.length)) {
73
74
  onSelectIndex(num - 1);
74
75
  onClose();
75
76
  }
@@ -134,23 +135,31 @@ export const SessionPicker = ({ isOpen, sessions, activeIndex, sessionUIStates,
134
135
  : answered > 0
135
136
  ? theme.components.sessionPicker.progress
136
137
  : theme.components.sessionPicker.rowDim;
137
- return (React.createElement(Box, { key: session.sessionId },
138
- React.createElement(Text, { backgroundColor: rowBg, bold: isHighlighted, color: textColor },
139
- isActive ? "►" : " ",
140
- " ",
141
- realIdx + 1,
142
- ". ",
143
- title),
144
- React.createElement(Text, { backgroundColor: rowBg, color: theme.components.sessionPicker.rowDim },
145
- " ",
146
- dir),
147
- React.createElement(Text, { backgroundColor: rowBg, color: progressColor },
148
- " [",
149
- answered,
150
- "/",
151
- total,
152
- "]"),
153
- 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")))));
154
163
  }),
155
164
  needsScroll && scrollOffset + maxVisibleRows < sessions.length && (React.createElement(Box, { justifyContent: "center" },
156
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";
@@ -7,6 +7,7 @@ import { getSessionDirectory } from "../../session/utils.js";
7
7
  import { useTheme } from "../ThemeContext.js";
8
8
  import { useConfig } from "../ConfigContext.js";
9
9
  import { isRecommendedOption } from "../utils/recommended.js";
10
+ import { KEYS } from "../constants/keybindings.js";
10
11
  import { ConfirmationDialog } from "./ConfirmationDialog.js";
11
12
  import { QuestionDisplay } from "./QuestionDisplay.js";
12
13
  import { ReviewScreen } from "./ReviewScreen.js";
@@ -14,7 +15,7 @@ import { ReviewScreen } from "./ReviewScreen.js";
14
15
  * StepperView orchestrates the question-answering flow
15
16
  * Manages state for current question, answers, and navigation
16
17
  */
17
- 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, }) => {
18
19
  const { theme } = useTheme();
19
20
  const config = useConfig();
20
21
  const { exit } = useApp();
@@ -23,6 +24,9 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
23
24
  const [showReview, setShowReview] = useState(false);
24
25
  const [submitting, setSubmitting] = useState(false);
25
26
  const [showRejectionConfirm, setShowRejectionConfirm] = useState(false);
27
+ const [showAbandonedConfirm, setShowAbandonedConfirm] = useState(false);
28
+ const [abandonedConfirmed, setAbandonedConfirmed] = useState(false);
29
+ const [abandonedFocusedIndex, setAbandonedFocusedIndex] = useState(0);
26
30
  const [elapsedSeconds, setElapsedSeconds] = useState(0);
27
31
  const [focusContext, setFocusContext] = useState("option");
28
32
  const [focusedOptionIndex, setFocusedOptionIndex] = useState(0);
@@ -179,6 +183,16 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
179
183
  const anyHasRecommended = sessionRequest.questions.some((question) => question.options.some((opt) => isRecommendedOption(opt.label)));
180
184
  setHasAnyRecommendedInSession(anyHasRecommended);
181
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]);
182
196
  useEffect(() => {
183
197
  if (!onStateSnapshot) {
184
198
  return;
@@ -206,8 +220,8 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
206
220
  showReview,
207
221
  ]);
208
222
  useEffect(() => {
209
- onFlowStateChange?.({ showReview, showRejectionConfirm });
210
- }, [onFlowStateChange, showRejectionConfirm, showReview]);
223
+ onFlowStateChange?.({ showReview, showRejectionConfirm, showAbandonedConfirm });
224
+ }, [onFlowStateChange, showRejectionConfirm, showReview, showAbandonedConfirm]);
211
225
  // Update elapsed time since session creation
212
226
  // IMPORTANT: Pause when content overflows terminal to prevent scroll-snapping
213
227
  useEffect(() => {
@@ -342,10 +356,35 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
342
356
  return newMarks;
343
357
  });
344
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
+ });
345
384
  // Global keyboard shortcuts and navigation
346
385
  useInput((input, key) => {
347
386
  // Don't handle navigation when showing review, submitting, or confirming rejection
348
- if (showReview || submitting || showRejectionConfirm)
387
+ if (showReview || submitting || showRejectionConfirm || showAbandonedConfirm)
349
388
  return;
350
389
  // Derive text-input state from both focusContext and focusedOptionIndex
351
390
  // focusContext may lag by one render cycle (set via useEffect in OptionsList)
@@ -358,7 +397,7 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
358
397
  return;
359
398
  }
360
399
  // Ctrl+R: Quick submit with recommended options (select all recommended and go to review)
361
- if (input.toLowerCase() === "r" &&
400
+ if (input.toLowerCase() === KEYS.QUICK_SUBMIT &&
362
401
  key.ctrl &&
363
402
  hasAnyRecommendedInSession &&
364
403
  !isInTextInput) {
@@ -395,7 +434,7 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
395
434
  return;
396
435
  }
397
436
  // R key: Select recommended options for current question
398
- if (input.toLowerCase() === "r" &&
437
+ if (input.toLowerCase() === KEYS.RECOMMEND &&
399
438
  !key.ctrl &&
400
439
  !isInTextInput &&
401
440
  hasRecommendedOptions) {
@@ -480,6 +519,31 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
480
519
  }
481
520
  }
482
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
+ }
483
547
  // Show rejection confirmation
484
548
  if (showRejectionConfirm) {
485
549
  return (React.createElement(Box, { flexDirection: "column", padding: 1 },