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
@@ -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 },
@@ -0,0 +1,29 @@
1
+ import { Box, Text } from "ink";
2
+ import React from "react";
3
+ import { useTheme } from "../ThemeContext.js";
4
+ /**
5
+ * UpdateBadge — compact header indicator shown when a new version is available.
6
+ *
7
+ * Color-coding by severity:
8
+ * patch → success (green) — minor fix, can auto-install
9
+ * minor → warning (yellow) — new features
10
+ * major → error (red) — possible breaking changes
11
+ *
12
+ * Rendered inside Header next to the version text.
13
+ */
14
+ export const UpdateBadge = ({ updateType, latestVersion, }) => {
15
+ const { theme } = useTheme();
16
+ // Map update severity to theme colors
17
+ const colorMap = {
18
+ patch: theme.colors.success,
19
+ minor: theme.colors.warning,
20
+ major: theme.colors.error,
21
+ };
22
+ const color = colorMap[updateType] ?? theme.colors.info;
23
+ // Patch updates are concise (they auto-install); minor/major show the target version
24
+ const label = updateType === "patch"
25
+ ? "↑ Update"
26
+ : `↑ v${latestVersion}`;
27
+ return (React.createElement(Box, { marginLeft: 1 },
28
+ React.createElement(Text, { backgroundColor: color, bold: true, color: "#000000" }, ` ${label} `)));
29
+ };
@@ -0,0 +1,199 @@
1
+ import { Box, Text, useInput, useStdout } from "ink";
2
+ import React, { useState } from "react";
3
+ import Markdown from "ink-markdown-es";
4
+ import { lexer } from "marked";
5
+ import { useTheme } from "../ThemeContext.js";
6
+ import { detectPackageManager } from "../../update/package-manager.js";
7
+ import { getManualCommand } from "../../update/installer.js";
8
+ /**
9
+ * UpdateOverlay — fullscreen modal for minor/major update prompts.
10
+ *
11
+ * Displays version information, changelog (rendered as Markdown), and
12
+ * action buttons. Major updates show a breaking-change warning badge.
13
+ *
14
+ * Navigation:
15
+ * Tab / → : next button
16
+ * Shift+Tab / ← : previous button
17
+ * Enter : trigger focused action
18
+ * Esc : same as "Remind me later"
19
+ */
20
+ export const UpdateOverlay = ({ isOpen, currentVersion, latestVersion, updateType, changelog, changelogUrl, isInstalling, installError, onInstall, onSkipVersion, onRemindLater, }) => {
21
+ const { theme } = useTheme();
22
+ const { stdout } = useStdout();
23
+ const termWidth = stdout?.columns ?? 80;
24
+ const [focusedButton, setFocusedButton] = useState(0);
25
+ // ── Actions bound to button indices ──────────────────────────────
26
+ const actions = [onInstall, onSkipVersion, onRemindLater];
27
+ const buttonLabels = ["Yes, update", "Skip this version", "Remind me later"];
28
+ const buttonCount = buttonLabels.length;
29
+ // ── Keyboard handling (only active when overlay is open) ────────
30
+ useInput((input, key) => {
31
+ // Installing or error state — only Esc closes
32
+ if (isInstalling)
33
+ return;
34
+ if (installError) {
35
+ if (key.return || key.escape) {
36
+ onRemindLater();
37
+ }
38
+ return;
39
+ }
40
+ // Tab / Right arrow: next button
41
+ if (key.tab && !key.shift) {
42
+ setFocusedButton((prev) => (prev + 1) % buttonCount);
43
+ return;
44
+ }
45
+ if (key.rightArrow) {
46
+ setFocusedButton((prev) => (prev + 1) % buttonCount);
47
+ return;
48
+ }
49
+ // Shift+Tab / Left arrow: previous button
50
+ if (key.tab && key.shift) {
51
+ setFocusedButton((prev) => (prev - 1 + buttonCount) % buttonCount);
52
+ return;
53
+ }
54
+ if (key.leftArrow) {
55
+ setFocusedButton((prev) => (prev - 1 + buttonCount) % buttonCount);
56
+ return;
57
+ }
58
+ // Enter: trigger focused button
59
+ if (key.return) {
60
+ actions[focusedButton]();
61
+ return;
62
+ }
63
+ // Escape: remind me later
64
+ if (key.escape) {
65
+ onRemindLater();
66
+ return;
67
+ }
68
+ }, { isActive: isOpen });
69
+ // ── Render nothing when closed ──────────────────────────────────
70
+ if (!isOpen)
71
+ return null;
72
+ // ── Color mapping for update type badges ────────────────────────
73
+ const typeColorMap = {
74
+ patch: theme.colors.success,
75
+ minor: theme.colors.warning,
76
+ major: theme.colors.error,
77
+ };
78
+ const typeColor = typeColorMap[updateType] ?? theme.colors.info;
79
+ const overlayWidth = Math.min(64, termWidth - 4);
80
+ const innerWidth = overlayWidth - 6; // account for border + paddingX
81
+ // ── Package manager info for error display ──────────────────────
82
+ const packageManager = detectPackageManager();
83
+ const manualCommand = getManualCommand(packageManager);
84
+ // ── Markdown styles matching MarkdownPrompt ─────────────────────
85
+ const markdownStyles = {
86
+ code: {
87
+ backgroundColor: theme.components.markdown.codeBlockBg,
88
+ color: theme.components.markdown.codeBlockText,
89
+ borderColor: theme.components.markdown.codeBlockBorder,
90
+ borderStyle: "round",
91
+ paddingX: 1,
92
+ },
93
+ codespan: {
94
+ backgroundColor: theme.components.markdown.codeBlockBg,
95
+ color: theme.components.markdown.codeBlockText,
96
+ },
97
+ };
98
+ // ── Render changelog section ────────────────────────────────────
99
+ const renderChangelog = () => {
100
+ if (changelog) {
101
+ // Determine if changelog has block elements (like MarkdownPrompt)
102
+ let hasBlockElements = false;
103
+ try {
104
+ const tokens = lexer(changelog);
105
+ hasBlockElements = tokens.some((token) => ["code", "list", "blockquote", "heading", "hr", "table"].includes(token.type));
106
+ }
107
+ catch {
108
+ // fall through, treat as inline
109
+ }
110
+ const renderers = {
111
+ link: (linkText, href) => (React.createElement(Text, null,
112
+ linkText,
113
+ " (",
114
+ href,
115
+ ")")),
116
+ ...(!hasBlockElements
117
+ ? {
118
+ paragraph: (content) => (React.createElement(Text, null, content)),
119
+ }
120
+ : {}),
121
+ };
122
+ return (React.createElement(Box, { borderStyle: "round", borderColor: theme.borders.neutral, flexDirection: "column", paddingX: 1, paddingY: 1, width: innerWidth },
123
+ React.createElement(Markdown, { styles: markdownStyles, renderers: renderers, highlight: true }, changelog)));
124
+ }
125
+ // No changelog — show fallback link
126
+ return (React.createElement(Box, { borderStyle: "round", borderColor: theme.borders.neutral, paddingX: 1, paddingY: 1, width: innerWidth },
127
+ React.createElement(Text, { dimColor: true },
128
+ "View changelog: ",
129
+ changelogUrl)));
130
+ };
131
+ // ── Installing state ────────────────────────────────────────────
132
+ if (isInstalling) {
133
+ return (React.createElement(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center" },
134
+ React.createElement(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.borders.primary, paddingX: 2, paddingY: 1, width: overlayWidth, alignItems: "center" },
135
+ React.createElement(Box, { marginBottom: 1 },
136
+ React.createElement(Text, { bold: true, color: theme.colors.primary }, "Installing Update")),
137
+ React.createElement(Box, { marginBottom: 1 },
138
+ React.createElement(Text, null,
139
+ React.createElement(Text, { color: theme.colors.info }, "\u280B "),
140
+ "Installing v",
141
+ latestVersion,
142
+ "\u2026")),
143
+ React.createElement(Box, null,
144
+ React.createElement(Text, { dimColor: true },
145
+ "Running: ",
146
+ manualCommand)))));
147
+ }
148
+ // ── Error state ─────────────────────────────────────────────────
149
+ if (installError) {
150
+ return (React.createElement(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center" },
151
+ React.createElement(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.borders.error, paddingX: 2, paddingY: 1, width: overlayWidth },
152
+ React.createElement(Box, { marginBottom: 1 },
153
+ React.createElement(Text, { bold: true, color: theme.colors.error }, "Update Failed")),
154
+ React.createElement(Box, { marginBottom: 1 },
155
+ React.createElement(Text, { color: theme.colors.error }, installError)),
156
+ React.createElement(Box, { marginBottom: 1, flexDirection: "column" },
157
+ React.createElement(Text, { dimColor: true }, "Try running manually:"),
158
+ React.createElement(Box, { marginTop: 0 },
159
+ React.createElement(Text, { bold: true, color: theme.colors.info },
160
+ " ",
161
+ manualCommand))),
162
+ React.createElement(Box, { justifyContent: "center", marginTop: 1 },
163
+ React.createElement(Text, { bold: true, backgroundColor: theme.components.options.focusedBg, color: theme.colors.focused }, " Close ")),
164
+ React.createElement(Box, { justifyContent: "center", marginTop: 1 },
165
+ React.createElement(Text, { dimColor: true }, "Enter or Esc to close")))));
166
+ }
167
+ // ── Default state: update prompt with buttons ───────────────────
168
+ return (React.createElement(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center" },
169
+ React.createElement(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.borders.primary, paddingX: 2, paddingY: 1, width: overlayWidth },
170
+ React.createElement(Box, { justifyContent: "center", marginBottom: 1 },
171
+ React.createElement(Text, { bold: true, color: theme.colors.primary }, "Update Available")),
172
+ React.createElement(Box, { justifyContent: "center", marginBottom: 1 },
173
+ React.createElement(Text, null,
174
+ React.createElement(Text, { dimColor: true }, "Current: "),
175
+ React.createElement(Text, null, currentVersion),
176
+ React.createElement(Text, { dimColor: true }, " \u2192 "),
177
+ React.createElement(Text, { dimColor: true }, "Latest: "),
178
+ React.createElement(Text, { bold: true, color: typeColor }, latestVersion),
179
+ React.createElement(Text, null, " "),
180
+ React.createElement(Text, { backgroundColor: typeColor, color: "#000000", bold: true },
181
+ " ",
182
+ updateType.toUpperCase(),
183
+ " "))),
184
+ updateType === "major" && (React.createElement(Box, { borderStyle: "round", borderColor: theme.borders.warning, paddingX: 1, marginBottom: 1, width: innerWidth },
185
+ React.createElement(Text, { color: theme.colors.warning, bold: true }, "\u26A0 Breaking changes may be included"))),
186
+ React.createElement(Box, { marginBottom: 1 }, renderChangelog()),
187
+ React.createElement(Box, { justifyContent: "center", gap: 1 }, buttonLabels.map((label, index) => {
188
+ const isFocused = index === focusedButton;
189
+ return (React.createElement(Box, { key: label },
190
+ React.createElement(Text, { bold: isFocused, backgroundColor: isFocused
191
+ ? theme.components.options.focusedBg
192
+ : undefined, color: isFocused ? theme.colors.focused : theme.colors.text },
193
+ isFocused ? " ▸ " : " ",
194
+ label,
195
+ isFocused ? " " : " ")));
196
+ })),
197
+ React.createElement(Box, { justifyContent: "center", marginTop: 1 },
198
+ React.createElement(Text, { dimColor: true }, "\u2190\u2192/Tab navigate \u00B7 Enter select \u00B7 Esc dismiss")))));
199
+ };
@@ -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
  });
@@ -29,7 +29,7 @@ function renderWithTheme(ui) {
29
29
  function getOutput(frame) {
30
30
  return (frame ?? "").replace(/\x1b\[[0-9;]*m/g, "").replace(/\r/g, "");
31
31
  }
32
- function createSession(id) {
32
+ function createSession(id, overrides) {
33
33
  return {
34
34
  sessionId: `picker-id-${id}`,
35
35
  sessionRequest: {
@@ -47,6 +47,7 @@ function createSession(id) {
47
47
  ],
48
48
  },
49
49
  timestamp: new Date("2026-01-01T00:00:00.000Z"),
50
+ ...overrides,
50
51
  };
51
52
  }
52
53
  afterEach(() => {
@@ -122,4 +123,45 @@ describe("SessionPicker", () => {
122
123
  expect(onClose).toHaveBeenCalledTimes(1);
123
124
  expect(onSelectIndex).not.toHaveBeenCalled();
124
125
  });
126
+ describe("stale/abandoned session indicators", () => {
127
+ it("shows ⚠ icon for stale sessions", () => {
128
+ const sessions = [createSession(1), createSession(2, { isStale: true })];
129
+ const instance = renderWithTheme(React.createElement(SessionPicker, { isOpen: true, sessions: sessions, activeIndex: 0, sessionUIStates: {}, onSelectIndex: () => { }, onClose: () => { } }));
130
+ const output = getOutput(instance.lastFrame());
131
+ expect(output).toContain("⚠");
132
+ expect(output).toContain("Title 2");
133
+ });
134
+ it("shows 'may be orphaned' subtitle for stale sessions", () => {
135
+ const sessions = [createSession(1, { isStale: true })];
136
+ const instance = renderWithTheme(React.createElement(SessionPicker, { isOpen: true, sessions: sessions, activeIndex: 0, sessionUIStates: {}, onSelectIndex: () => { }, onClose: () => { } }));
137
+ const output = getOutput(instance.lastFrame());
138
+ expect(output).toContain("may be orphaned");
139
+ });
140
+ it("shows 'session abandoned' subtitle for abandoned sessions", () => {
141
+ const sessions = [createSession(1, { isAbandoned: true })];
142
+ const instance = renderWithTheme(React.createElement(SessionPicker, { isOpen: true, sessions: sessions, activeIndex: 0, sessionUIStates: {}, onSelectIndex: () => { }, onClose: () => { } }));
143
+ const output = getOutput(instance.lastFrame());
144
+ expect(output).toContain("⚠");
145
+ expect(output).toContain("session abandoned");
146
+ });
147
+ it("stale sessions remain selectable via Enter", async () => {
148
+ const sessions = [createSession(1, { isStale: true }), createSession(2)];
149
+ const onSelectIndex = vi.fn();
150
+ const onClose = vi.fn();
151
+ renderWithTheme(React.createElement(SessionPicker, { isOpen: true, sessions: sessions, activeIndex: 0, sessionUIStates: {}, onSelectIndex: onSelectIndex, onClose: onClose }));
152
+ expect(inputState.handler).not.toBeNull();
153
+ inputState.handler("", { return: true });
154
+ await Promise.resolve();
155
+ expect(onSelectIndex).toHaveBeenCalledWith(0);
156
+ expect(onClose).toHaveBeenCalled();
157
+ });
158
+ it("non-stale sessions render normally without ⚠ or subtitles", () => {
159
+ const sessions = [createSession(1), createSession(2)];
160
+ const instance = renderWithTheme(React.createElement(SessionPicker, { isOpen: true, sessions: sessions, activeIndex: 0, sessionUIStates: {}, onSelectIndex: () => { }, onClose: () => { } }));
161
+ const output = getOutput(instance.lastFrame());
162
+ expect(output).not.toContain("⚠");
163
+ expect(output).not.toContain("may be orphaned");
164
+ expect(output).not.toContain("session abandoned");
165
+ });
166
+ });
125
167
  });