auq-mcp-server 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -0
- package/dist/bin/auq.js +87 -93
- package/dist/bin/tui-app.js +183 -7
- package/dist/package.json +1 -1
- package/dist/src/__tests__/server.abort.test.js +214 -0
- package/dist/src/cli/commands/__tests__/answer.test.js +199 -0
- package/dist/src/cli/commands/__tests__/config.test.js +218 -0
- package/dist/src/cli/commands/__tests__/sessions.test.js +282 -0
- package/dist/src/cli/commands/answer.js +128 -0
- package/dist/src/cli/commands/config.js +263 -0
- package/dist/src/cli/commands/sessions.js +300 -0
- package/dist/src/cli/commands/update.js +124 -0
- package/dist/src/cli/utils.js +95 -0
- package/dist/src/config/__tests__/ConfigLoader.test.js +41 -0
- package/dist/src/config/__tests__/updateCheck.test.js +34 -0
- package/dist/src/config/defaults.js +5 -0
- package/dist/src/config/types.js +6 -0
- package/dist/src/core/ask-user-questions.js +3 -2
- package/dist/src/i18n/locales/en.js +7 -0
- package/dist/src/i18n/locales/ko.js +7 -0
- package/dist/src/server.js +64 -11
- package/dist/src/session/SessionManager.js +69 -4
- package/dist/src/session/__tests__/SessionManager.test.js +65 -0
- package/dist/src/tui/__tests__/session-watcher.test.js +109 -0
- package/dist/src/tui/components/Footer.js +4 -1
- package/dist/src/tui/components/Header.js +3 -1
- package/dist/src/tui/components/SessionDots.js +33 -4
- package/dist/src/tui/components/SessionPicker.js +25 -17
- package/dist/src/tui/components/StepperView.js +68 -5
- package/dist/src/tui/components/UpdateBadge.js +29 -0
- package/dist/src/tui/components/UpdateOverlay.js +199 -0
- package/dist/src/tui/components/__tests__/SessionDots.test.js +160 -1
- package/dist/src/tui/components/__tests__/SessionPicker.test.js +43 -1
- package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +160 -0
- package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -0
- package/dist/src/tui/constants/keybindings.js +3 -0
- package/dist/src/tui/session-watcher.js +50 -0
- package/dist/src/tui/themes/catppuccin-latte.js +7 -0
- package/dist/src/tui/themes/catppuccin-mocha.js +7 -0
- package/dist/src/tui/themes/dark.js +7 -0
- package/dist/src/tui/themes/dracula.js +7 -0
- package/dist/src/tui/themes/github-dark.js +7 -0
- package/dist/src/tui/themes/github-light.js +7 -0
- package/dist/src/tui/themes/gruvbox-dark.js +7 -0
- package/dist/src/tui/themes/gruvbox-light.js +7 -0
- package/dist/src/tui/themes/light.js +7 -0
- package/dist/src/tui/themes/monokai.js +7 -0
- package/dist/src/tui/themes/nord.js +7 -0
- package/dist/src/tui/themes/one-dark.js +7 -0
- package/dist/src/tui/themes/rose-pine.js +7 -0
- package/dist/src/tui/themes/solarized-dark.js +7 -0
- package/dist/src/tui/themes/solarized-light.js +7 -0
- package/dist/src/tui/themes/tokyo-night.js +7 -0
- package/dist/src/tui/utils/__tests__/staleDetection.test.js +118 -0
- package/dist/src/tui/utils/staleDetection.js +51 -0
- package/dist/src/update/__tests__/cache.test.js +136 -0
- package/dist/src/update/__tests__/changelog.test.js +86 -0
- package/dist/src/update/__tests__/checker.test.js +148 -0
- package/dist/src/update/__tests__/index.test.js +37 -0
- package/dist/src/update/__tests__/installer.test.js +117 -0
- package/dist/src/update/__tests__/package-manager.test.js +73 -0
- package/dist/src/update/__tests__/version.test.js +74 -0
- package/dist/src/update/cache.js +74 -0
- package/dist/src/update/changelog.js +63 -0
- package/dist/src/update/checker.js +121 -0
- package/dist/src/update/index.js +15 -0
- package/dist/src/update/installer.js +51 -0
- package/dist/src/update/package-manager.js +49 -0
- package/dist/src/update/types.js +7 -0
- package/dist/src/update/version.js +114 -0
- package/package.json +1 -1
|
@@ -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
|
});
|