auq-mcp-server 2.2.2 → 2.3.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/dist/bin/auq.js +77 -25
- package/dist/bin/tui-app.js +9 -2
- package/dist/package.json +1 -1
- package/dist/src/i18n/locales/en.js +1 -1
- package/dist/src/i18n/locales/ko.js +1 -1
- package/dist/src/tui/ThemeProvider.js +2 -1
- package/dist/src/tui/components/ConfirmationDialog.js +5 -4
- package/dist/src/tui/components/Footer.js +24 -23
- package/dist/src/tui/components/ReviewScreen.js +2 -1
- package/dist/src/tui/components/SessionPicker.js +2 -1
- package/dist/src/tui/components/StepperView.js +3 -2
- package/dist/src/tui/components/WaitingScreen.js +2 -1
- package/dist/src/tui/components/__tests__/ConfirmationDialog.test.js +134 -0
- package/dist/src/tui/components/__tests__/Footer.test.js +121 -0
- package/dist/src/tui/components/__tests__/ReviewScreen.test.js +89 -0
- package/dist/src/tui/components/__tests__/StepperView.keyboard.test.js +135 -0
- package/dist/src/tui/components/__tests__/WaitingScreen.test.js +60 -0
- package/dist/src/tui/constants/keybindings.js +40 -0
- package/package.json +1 -1
package/dist/bin/auq.js
CHANGED
|
@@ -12,46 +12,98 @@ if (command === "--help" || command === "-h") {
|
|
|
12
12
|
console.log(`
|
|
13
13
|
AUQ - Ask User Questions
|
|
14
14
|
|
|
15
|
+
An MCP server and TUI for AI assistants to ask users structured questions.
|
|
16
|
+
|
|
15
17
|
Usage:
|
|
16
18
|
auq [command] [options]
|
|
17
19
|
|
|
18
20
|
Commands:
|
|
19
|
-
(default) Start the TUI (Terminal User Interface)
|
|
20
|
-
server Start the MCP server (for use with
|
|
21
|
-
ask <json> Ask questions via CLI (
|
|
21
|
+
(default) Start the interactive TUI (Terminal User Interface)
|
|
22
|
+
server Start the MCP server (for use with AI assistants)
|
|
23
|
+
ask <json> Ask questions via CLI (pipe or argument)
|
|
22
24
|
|
|
23
25
|
Options:
|
|
24
26
|
--help, -h Show this help message
|
|
25
27
|
--version, -v Show version information
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
29
|
+
TUI Keyboard Shortcuts:
|
|
30
|
+
Navigation:
|
|
31
|
+
↑/↓ Navigate options
|
|
32
|
+
←/→ Navigate questions
|
|
33
|
+
Tab/Shift+Tab Navigate questions
|
|
34
|
+
|
|
35
|
+
Selection:
|
|
36
|
+
Space Select/toggle option (multi-select)
|
|
37
|
+
Enter Select option & advance to next question
|
|
38
|
+
R Select recommended option(s)
|
|
39
|
+
Ctrl+R Quick submit (auto-select all recommended)
|
|
40
|
+
|
|
41
|
+
Session Management:
|
|
42
|
+
] Next session
|
|
43
|
+
[ Previous session
|
|
44
|
+
1-9 Jump to session by number
|
|
45
|
+
Ctrl+S Open session picker
|
|
46
|
+
|
|
47
|
+
Other:
|
|
48
|
+
E Request elaboration on current question
|
|
49
|
+
Ctrl+T Cycle color theme
|
|
50
|
+
Esc Reject question set
|
|
51
|
+
|
|
52
|
+
Ask Command:
|
|
53
|
+
Use 'auq ask' when you need to ask the user questions during
|
|
54
|
+
execution. This allows you to:
|
|
55
|
+
1. Gather user preferences or requirements
|
|
56
|
+
2. Clarify ambiguous instructions
|
|
57
|
+
3. Get decisions on implementation choices as you work
|
|
58
|
+
4. Offer choices to the user about what direction to take
|
|
59
|
+
|
|
60
|
+
Features:
|
|
61
|
+
- Ask 1-5 structured questions via an interactive terminal UI
|
|
62
|
+
- Each question includes 2-5 multiple-choice options
|
|
63
|
+
- Users can always provide custom free-text input
|
|
64
|
+
- Single-select mode (default): pick ONE option or custom text
|
|
65
|
+
- Multi-select mode (multiSelect: true): select MULTIPLE options
|
|
66
|
+
|
|
67
|
+
Usage Notes:
|
|
68
|
+
- Provide a descriptive 'title' field (max 12 chars) per question
|
|
69
|
+
- Use multiSelect: true when choices are not mutually exclusive
|
|
70
|
+
- Option labels should be concise (1-5 words)
|
|
71
|
+
- To mark recommended, append '(recommended)' to option label
|
|
72
|
+
- Don't include an 'Other' option — it's provided automatically
|
|
48
73
|
|
|
49
74
|
Returns a formatted summary of all questions and answers.
|
|
50
75
|
|
|
76
|
+
Configuration:
|
|
77
|
+
Config file locations (searched in order, merged):
|
|
78
|
+
./.auqrc.json Project-level (highest priority)
|
|
79
|
+
~/.config/auq/.auqrc.json User-level (global)
|
|
80
|
+
|
|
81
|
+
Available options (with defaults):
|
|
82
|
+
maxOptions Max options per question (2-10, default: 5)
|
|
83
|
+
maxQuestions Max questions per session (1-10, default: 5)
|
|
84
|
+
recommendedOptions Recommended option count hint (default: 4)
|
|
85
|
+
recommendedQuestions Recommended question count hint (default: 4)
|
|
86
|
+
sessionTimeout Session timeout in ms (0 = infinite, default: 0)
|
|
87
|
+
retentionPeriod Session retention in ms (default: 604800000 / 7d)
|
|
88
|
+
language UI language ("auto" | "en" | "ko", default: "auto")
|
|
89
|
+
theme Color theme ("system" | "dark" | "light" | custom,
|
|
90
|
+
default: "system")
|
|
91
|
+
autoSelectRecommended Pre-select recommended options (default: true)
|
|
92
|
+
notifications.enabled Enable desktop notifications (default: true)
|
|
93
|
+
notifications.sound Enable notification sounds (default: true)
|
|
94
|
+
|
|
95
|
+
Custom themes: place .theme.json files in ~/.config/auq/themes/
|
|
96
|
+
|
|
97
|
+
Environment Variables:
|
|
98
|
+
AUQ_SESSION_DIR Override session storage directory
|
|
99
|
+
XDG_CONFIG_HOME Override config base directory (default: ~/.config)
|
|
100
|
+
|
|
51
101
|
Examples:
|
|
52
102
|
auq # Start TUI (wait for questions from AI)
|
|
53
103
|
auq server # Start MCP server (for Claude Desktop, etc.)
|
|
54
|
-
auq ask '{"questions": [{"prompt": "Which language?", "title": "Lang",
|
|
104
|
+
auq ask '{"questions": [{"prompt": "Which language?", "title": "Lang",
|
|
105
|
+
"options": [{"label": "TypeScript (recommended)"}, {"label": "Python"}],
|
|
106
|
+
"multiSelect": false}]}'
|
|
55
107
|
echo '{"questions": [...]}' | auq ask # Pipe JSON to ask command
|
|
56
108
|
|
|
57
109
|
For more information, visit:
|
package/dist/bin/tui-app.js
CHANGED
|
@@ -14,6 +14,7 @@ import { createTUIWatcher } from "../src/tui/session-watcher.js";
|
|
|
14
14
|
import { ThemeProvider } from "../src/tui/ThemeProvider.js";
|
|
15
15
|
import { ConfigProvider } from "../src/tui/ConfigContext.js";
|
|
16
16
|
import { getAdjustedIndexAfterRemoval, getDirectJumpIndex, getNextSessionIndex, getPrevSessionIndex, } from "../src/tui/utils/sessionSwitching.js";
|
|
17
|
+
import { KEYS } from "../src/tui/constants/keybindings.js";
|
|
17
18
|
const App = ({ config }) => {
|
|
18
19
|
const [state, setState] = useState({ mode: "WAITING" });
|
|
19
20
|
const [sessionQueue, setSessionQueue] = useState([]);
|
|
@@ -257,12 +258,18 @@ const App = ({ config }) => {
|
|
|
257
258
|
setShowSessionPicker(true);
|
|
258
259
|
return;
|
|
259
260
|
}
|
|
260
|
-
if (key.ctrl && input ===
|
|
261
|
+
if (!key.ctrl && !key.meta && input === KEYS.SESSION_NEXT) {
|
|
262
|
+
if (!canUseDirectJump) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
261
265
|
const nextIndex = getNextSessionIndex(activeSessionIndex, sessionQueue.length);
|
|
262
266
|
switchToSession(nextIndex);
|
|
263
267
|
return;
|
|
264
268
|
}
|
|
265
|
-
if (key.ctrl && input ===
|
|
269
|
+
if (!key.ctrl && !key.meta && input === KEYS.SESSION_PREV) {
|
|
270
|
+
if (!canUseDirectJump) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
266
273
|
const prevIndex = getPrevSessionIndex(activeSessionIndex, sessionQueue.length);
|
|
267
274
|
switchToSession(prevIndex);
|
|
268
275
|
return;
|
package/dist/package.json
CHANGED
|
@@ -45,7 +45,7 @@ export const en = {
|
|
|
45
45
|
},
|
|
46
46
|
input: {
|
|
47
47
|
customAnswerLabel: "Custom answer",
|
|
48
|
-
customAnswerHint: "(
|
|
48
|
+
customAnswerHint: "(Tab to submit)",
|
|
49
49
|
otherCustom: "Other (custom)",
|
|
50
50
|
placeholder: "Type your answer (Enter = newline, Tab = done)",
|
|
51
51
|
singleLinePlaceholder: "Type here...",
|
|
@@ -45,7 +45,7 @@ export const ko = {
|
|
|
45
45
|
},
|
|
46
46
|
input: {
|
|
47
47
|
customAnswerLabel: "직접 입력",
|
|
48
|
-
customAnswerHint: "(Tab
|
|
48
|
+
customAnswerHint: "(Tab으로 제출)",
|
|
49
49
|
otherCustom: "기타 (직접 입력)",
|
|
50
50
|
placeholder: "답변을 입력하세요 (Enter = 줄바꿈, Tab = 완료)",
|
|
51
51
|
singleLinePlaceholder: "여기에 입력...",
|
|
@@ -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 ===
|
|
46
|
+
if (key.ctrl && input === KEYS.THEME_CYCLE) {
|
|
46
47
|
cycleTheme();
|
|
47
48
|
}
|
|
48
49
|
});
|
|
@@ -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) => (
|
|
41
|
+
setFocusedIndex((prev) => Math.max(0, prev - 1));
|
|
41
42
|
}
|
|
42
43
|
if (key.downArrow) {
|
|
43
|
-
setFocusedIndex((prev) => (
|
|
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
|
|
51
|
+
if (KEYS.CONFIRM_YES.test(input)) {
|
|
51
52
|
setShowReasonInput(true);
|
|
52
53
|
}
|
|
53
|
-
if (input
|
|
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:
|
|
27
|
-
{ key:
|
|
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:
|
|
34
|
-
{ key:
|
|
35
|
-
{ key:
|
|
36
|
-
{ key:
|
|
37
|
-
{ key:
|
|
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:
|
|
44
|
-
{ key:
|
|
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:
|
|
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:
|
|
53
|
-
{ key:
|
|
54
|
-
{ key:
|
|
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:
|
|
58
|
-
bindings.push({ key:
|
|
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:
|
|
62
|
-
bindings.push({ key:
|
|
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:
|
|
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:
|
|
70
|
+
bindings.push({ key: KEY_LABELS.QUICK_SUBMIT, action: t("footer.quickSubmit") });
|
|
70
71
|
}
|
|
71
72
|
if (showSessionSwitching) {
|
|
72
|
-
bindings.push({ key:
|
|
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:
|
|
75
|
+
bindings.push({ key: KEY_LABELS.SESSION_LIST, action: t("footer.list") });
|
|
75
76
|
}
|
|
76
|
-
bindings.push({ key:
|
|
77
|
-
bindings.push({ key:
|
|
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
|
|
36
|
+
if (KEYS.GO_BACK.test(input)) {
|
|
36
37
|
onGoBack();
|
|
37
38
|
}
|
|
38
39
|
});
|
|
@@ -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 >=
|
|
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
|
}
|
|
@@ -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";
|
|
@@ -358,7 +359,7 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
|
|
|
358
359
|
return;
|
|
359
360
|
}
|
|
360
361
|
// Ctrl+R: Quick submit with recommended options (select all recommended and go to review)
|
|
361
|
-
if (input.toLowerCase() ===
|
|
362
|
+
if (input.toLowerCase() === KEYS.QUICK_SUBMIT &&
|
|
362
363
|
key.ctrl &&
|
|
363
364
|
hasAnyRecommendedInSession &&
|
|
364
365
|
!isInTextInput) {
|
|
@@ -395,7 +396,7 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
|
|
|
395
396
|
return;
|
|
396
397
|
}
|
|
397
398
|
// R key: Select recommended options for current question
|
|
398
|
-
if (input.toLowerCase() ===
|
|
399
|
+
if (input.toLowerCase() === KEYS.RECOMMEND &&
|
|
399
400
|
!key.ctrl &&
|
|
400
401
|
!isInTextInput &&
|
|
401
402
|
hasRecommendedOptions) {
|
|
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from "react";
|
|
|
3
3
|
import { AnimatedGradient } from "./AnimatedGradient.js";
|
|
4
4
|
import { useTheme } from "../ThemeContext.js";
|
|
5
5
|
import { t } from "../../i18n/index.js";
|
|
6
|
+
import { KEYS } from "../constants/keybindings.js";
|
|
6
7
|
/**
|
|
7
8
|
* WaitingScreen displays when no question sets are being processed
|
|
8
9
|
* Shows "Waiting for AI..." message or queue status
|
|
@@ -22,7 +23,7 @@ export const WaitingScreen = ({ queueCount }) => {
|
|
|
22
23
|
}, [startTime]);
|
|
23
24
|
// Handle 'q' key to quit
|
|
24
25
|
useInput((input, key) => {
|
|
25
|
-
if (input
|
|
26
|
+
if (KEYS.QUIT.test(input)) {
|
|
26
27
|
process.exit(0);
|
|
27
28
|
}
|
|
28
29
|
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cleanup, render } from "ink-testing-library";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
const inputState = vi.hoisted(() => ({
|
|
5
|
+
handler: null,
|
|
6
|
+
}));
|
|
7
|
+
vi.mock("ink", async () => {
|
|
8
|
+
const actual = await vi.importActual("ink");
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
useInput: (handler, options) => {
|
|
12
|
+
// ConfirmationDialog calls useInput without options, so always capture
|
|
13
|
+
if (!options || options.isActive !== false) {
|
|
14
|
+
inputState.handler = handler;
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
import { ThemeContext } from "../../ThemeContext.js";
|
|
20
|
+
import { darkTheme } from "../../themes/dark.js";
|
|
21
|
+
import { ConfirmationDialog } from "../ConfirmationDialog.js";
|
|
22
|
+
const mockThemeValue = {
|
|
23
|
+
theme: darkTheme,
|
|
24
|
+
themeName: "AUQ dark",
|
|
25
|
+
cycleTheme: () => { },
|
|
26
|
+
};
|
|
27
|
+
function renderWithTheme(ui) {
|
|
28
|
+
return render(React.createElement(ThemeContext.Provider, { value: mockThemeValue }, ui));
|
|
29
|
+
}
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
cleanup();
|
|
32
|
+
inputState.handler = null;
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
});
|
|
35
|
+
describe("ConfirmationDialog keyboard handling", () => {
|
|
36
|
+
const defaultProps = {
|
|
37
|
+
message: "Test confirmation",
|
|
38
|
+
onReject: vi.fn(),
|
|
39
|
+
onCancel: vi.fn(),
|
|
40
|
+
onQuit: vi.fn(),
|
|
41
|
+
};
|
|
42
|
+
function renderDialog(overrides = {}) {
|
|
43
|
+
const props = {
|
|
44
|
+
...defaultProps,
|
|
45
|
+
onReject: vi.fn(),
|
|
46
|
+
onCancel: vi.fn(),
|
|
47
|
+
onQuit: vi.fn(),
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
const instance = renderWithTheme(React.createElement(ConfirmationDialog, { ...props }));
|
|
51
|
+
return { instance, ...props };
|
|
52
|
+
}
|
|
53
|
+
it("arrow down at last item stays at last item (clamping)", async () => {
|
|
54
|
+
const { onCancel, instance } = renderDialog();
|
|
55
|
+
expect(inputState.handler).not.toBeNull();
|
|
56
|
+
// There are 2 options (index 0 and 1). Press down to go to last.
|
|
57
|
+
inputState.handler("", { downArrow: true });
|
|
58
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
59
|
+
// Press down again — should clamp at last item, not wrap to first
|
|
60
|
+
inputState.handler("", { downArrow: true });
|
|
61
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
62
|
+
// Press down a third time — still clamped at last
|
|
63
|
+
inputState.handler("", { downArrow: true });
|
|
64
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
65
|
+
// Now press Enter — should select the last option (index 1 = onCancel)
|
|
66
|
+
inputState.handler("", { return: true });
|
|
67
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
68
|
+
// The second option's action is onCancel
|
|
69
|
+
expect(onCancel).toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
it("arrow up at first item stays at first item (clamping)", async () => {
|
|
72
|
+
const { onCancel } = renderDialog();
|
|
73
|
+
expect(inputState.handler).not.toBeNull();
|
|
74
|
+
// At index 0, press up multiple times
|
|
75
|
+
inputState.handler("", { upArrow: true });
|
|
76
|
+
await Promise.resolve();
|
|
77
|
+
inputState.handler("", { upArrow: true });
|
|
78
|
+
await Promise.resolve();
|
|
79
|
+
// Press Enter — should select first option (index 0 = setShowReasonInput)
|
|
80
|
+
// The first option's action is setShowReasonInput(true), not onCancel
|
|
81
|
+
inputState.handler("", { return: true });
|
|
82
|
+
await Promise.resolve();
|
|
83
|
+
// onCancel should NOT have been called (that's the second option)
|
|
84
|
+
expect(onCancel).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
it("Enter selects focused option", async () => {
|
|
87
|
+
const { onCancel } = renderDialog();
|
|
88
|
+
expect(inputState.handler).not.toBeNull();
|
|
89
|
+
// Default focus is index 0 (Yes option). Press Enter.
|
|
90
|
+
inputState.handler("", { return: true });
|
|
91
|
+
await Promise.resolve();
|
|
92
|
+
// First option sets showReasonInput=true, does NOT call onCancel
|
|
93
|
+
expect(onCancel).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
it("y key triggers yes action", async () => {
|
|
96
|
+
renderDialog();
|
|
97
|
+
expect(inputState.handler).not.toBeNull();
|
|
98
|
+
// Press 'y' — should trigger CONFIRM_YES shortcut (setShowReasonInput)
|
|
99
|
+
inputState.handler("y", {});
|
|
100
|
+
await Promise.resolve();
|
|
101
|
+
// After pressing y, the component transitions to reason input mode
|
|
102
|
+
// We verify it didn't call onCancel or onQuit
|
|
103
|
+
// (The actual transition to reason input is internal state)
|
|
104
|
+
});
|
|
105
|
+
it("Y key (uppercase) triggers yes action (case-insensitive)", async () => {
|
|
106
|
+
renderDialog();
|
|
107
|
+
expect(inputState.handler).not.toBeNull();
|
|
108
|
+
// Press 'Y' — KEYS.CONFIRM_YES is /^[yY]$/, should match
|
|
109
|
+
inputState.handler("Y", {});
|
|
110
|
+
await Promise.resolve();
|
|
111
|
+
// Should work same as lowercase 'y'
|
|
112
|
+
});
|
|
113
|
+
it("n key triggers cancel action", async () => {
|
|
114
|
+
const { onCancel } = renderDialog();
|
|
115
|
+
expect(inputState.handler).not.toBeNull();
|
|
116
|
+
inputState.handler("n", {});
|
|
117
|
+
await Promise.resolve();
|
|
118
|
+
expect(onCancel).toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
it("N key (uppercase) triggers cancel action (case-insensitive)", async () => {
|
|
121
|
+
const { onCancel } = renderDialog();
|
|
122
|
+
expect(inputState.handler).not.toBeNull();
|
|
123
|
+
inputState.handler("N", {});
|
|
124
|
+
await Promise.resolve();
|
|
125
|
+
expect(onCancel).toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
it("Escape triggers quit", async () => {
|
|
128
|
+
const { onQuit } = renderDialog();
|
|
129
|
+
expect(inputState.handler).not.toBeNull();
|
|
130
|
+
inputState.handler("", { escape: true });
|
|
131
|
+
await Promise.resolve();
|
|
132
|
+
expect(onQuit).toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cleanup, render } from "ink-testing-library";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { ThemeContext } from "../../ThemeContext.js";
|
|
5
|
+
import { darkTheme } from "../../themes/dark.js";
|
|
6
|
+
import { KEY_LABELS } from "../../constants/keybindings.js";
|
|
7
|
+
import { Footer } from "../Footer.js";
|
|
8
|
+
const mockThemeValue = {
|
|
9
|
+
theme: darkTheme,
|
|
10
|
+
themeName: "AUQ dark",
|
|
11
|
+
cycleTheme: () => { },
|
|
12
|
+
};
|
|
13
|
+
function renderWithTheme(ui) {
|
|
14
|
+
return render(React.createElement(ThemeContext.Provider, { value: mockThemeValue }, ui));
|
|
15
|
+
}
|
|
16
|
+
function getOutput(frame) {
|
|
17
|
+
return (frame ?? "").replace(/\x1b\[[0-9;]*m/g, "").replace(/\r/g, "");
|
|
18
|
+
}
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
cleanup();
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
});
|
|
23
|
+
describe("Footer keybinding labels", () => {
|
|
24
|
+
it("option context (single-select) shows correct keybindings", () => {
|
|
25
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false }));
|
|
26
|
+
const output = getOutput(instance.lastFrame());
|
|
27
|
+
expect(output).toContain(KEY_LABELS.SELECT); // "Space"
|
|
28
|
+
expect(output).toContain(KEY_LABELS.SELECT_NEXT); // "Enter"
|
|
29
|
+
expect(output).toContain(KEY_LABELS.REJECT); // "Esc"
|
|
30
|
+
expect(output).toContain(KEY_LABELS.NAVIGATE_OPTIONS); // "↑↓"
|
|
31
|
+
expect(output).toContain(KEY_LABELS.NAVIGATE_QUESTIONS); // "←→"
|
|
32
|
+
});
|
|
33
|
+
it("option context (multi-select) shows Toggle label", () => {
|
|
34
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: true }));
|
|
35
|
+
const output = getOutput(instance.lastFrame());
|
|
36
|
+
expect(output).toContain("Toggle");
|
|
37
|
+
expect(output).toContain(KEY_LABELS.SELECT); // "Space"
|
|
38
|
+
expect(output).toContain(KEY_LABELS.NEXT); // "Enter"
|
|
39
|
+
});
|
|
40
|
+
it("custom-input context shows correct keybindings", () => {
|
|
41
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "custom-input", multiSelect: false }));
|
|
42
|
+
const output = getOutput(instance.lastFrame());
|
|
43
|
+
expect(output).toContain(KEY_LABELS.NAVIGATE_OPTIONS); // "↑↓"
|
|
44
|
+
expect(output).toContain(KEY_LABELS.CURSOR); // "←→"
|
|
45
|
+
expect(output).toContain(KEY_LABELS.NAVIGATE_QUESTIONS_TAB); // "Tab/S+Tab"
|
|
46
|
+
expect(output).toContain(KEY_LABELS.NEWLINE); // "Enter"
|
|
47
|
+
expect(output).toContain(KEY_LABELS.REJECT); // "Esc"
|
|
48
|
+
});
|
|
49
|
+
it("elaborate-input context shows correct keybindings", () => {
|
|
50
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "elaborate-input", multiSelect: false }));
|
|
51
|
+
const output = getOutput(instance.lastFrame());
|
|
52
|
+
expect(output).toContain(KEY_LABELS.NAVIGATE_OPTIONS); // "↑↓"
|
|
53
|
+
expect(output).toContain(KEY_LABELS.CURSOR); // "←→"
|
|
54
|
+
expect(output).toContain("Enter/Tab");
|
|
55
|
+
expect(output).toContain(KEY_LABELS.REJECT); // "Esc"
|
|
56
|
+
});
|
|
57
|
+
it("review screen shows Submit and Back labels", () => {
|
|
58
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false, isReviewScreen: true }));
|
|
59
|
+
const output = getOutput(instance.lastFrame());
|
|
60
|
+
expect(output).toContain(KEY_LABELS.SUBMIT); // "Enter"
|
|
61
|
+
expect(output).toContain(KEY_LABELS.BACK); // "n"
|
|
62
|
+
expect(output).toContain("Submit");
|
|
63
|
+
expect(output).toContain("Back");
|
|
64
|
+
});
|
|
65
|
+
it("session switch shows ]/[ (not Ctrl+]/[)", () => {
|
|
66
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false, showSessionSwitching: true }));
|
|
67
|
+
const output = getOutput(instance.lastFrame());
|
|
68
|
+
// Should show "]/ [" session label
|
|
69
|
+
expect(output).toContain(KEY_LABELS.SESSION_SWITCH); // "]/["
|
|
70
|
+
// Must NOT contain Ctrl+] or Ctrl+[ anywhere
|
|
71
|
+
expect(output).not.toContain("Ctrl+]");
|
|
72
|
+
expect(output).not.toContain("Ctrl+[");
|
|
73
|
+
});
|
|
74
|
+
it("recommended key shown when hasRecommendedOptions is true", () => {
|
|
75
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false, hasRecommendedOptions: true }));
|
|
76
|
+
const output = getOutput(instance.lastFrame());
|
|
77
|
+
expect(output).toContain(KEY_LABELS.RECOMMEND); // "R"
|
|
78
|
+
expect(output).toContain("Recommended");
|
|
79
|
+
});
|
|
80
|
+
it("recommended key NOT shown when hasRecommendedOptions is false", () => {
|
|
81
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false, hasRecommendedOptions: false }));
|
|
82
|
+
const output = getOutput(instance.lastFrame());
|
|
83
|
+
expect(output).not.toContain("Recommended");
|
|
84
|
+
});
|
|
85
|
+
it("quick submit shown when hasAnyRecommendedInSession is true", () => {
|
|
86
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false, hasAnyRecommendedInSession: true }));
|
|
87
|
+
const output = getOutput(instance.lastFrame());
|
|
88
|
+
expect(output).toContain(KEY_LABELS.QUICK_SUBMIT); // "Ctrl+R"
|
|
89
|
+
expect(output).toContain("Quick Submit");
|
|
90
|
+
});
|
|
91
|
+
it("quick submit NOT shown when hasAnyRecommendedInSession is false", () => {
|
|
92
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false, hasAnyRecommendedInSession: false }));
|
|
93
|
+
const output = getOutput(instance.lastFrame());
|
|
94
|
+
expect(output).not.toContain("Quick Submit");
|
|
95
|
+
});
|
|
96
|
+
it("session switching shows 1-9 jump and Ctrl+S list labels", () => {
|
|
97
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false, showSessionSwitching: true }));
|
|
98
|
+
const output = getOutput(instance.lastFrame());
|
|
99
|
+
expect(output).toContain("1-9");
|
|
100
|
+
expect(output).toContain("Jump");
|
|
101
|
+
expect(output).toContain(KEY_LABELS.SESSION_LIST); // "Ctrl+S"
|
|
102
|
+
expect(output).toContain("List");
|
|
103
|
+
});
|
|
104
|
+
it("theme toggle is always shown in option context", () => {
|
|
105
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false }));
|
|
106
|
+
const output = getOutput(instance.lastFrame());
|
|
107
|
+
expect(output).toContain(KEY_LABELS.THEME); // "Ctrl+T"
|
|
108
|
+
expect(output).toContain("Theme");
|
|
109
|
+
});
|
|
110
|
+
it("review screen shows only submit and back, not option keybindings", () => {
|
|
111
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false, isReviewScreen: true }));
|
|
112
|
+
const output = getOutput(instance.lastFrame());
|
|
113
|
+
// Review screen should only show Submit and Back
|
|
114
|
+
expect(output).toContain("Submit");
|
|
115
|
+
expect(output).toContain("Back");
|
|
116
|
+
// Should NOT contain option-context keybindings
|
|
117
|
+
expect(output).not.toContain("Toggle");
|
|
118
|
+
expect(output).not.toContain("Theme");
|
|
119
|
+
expect(output).not.toContain("Questions");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cleanup, render } from "ink-testing-library";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
const inputState = vi.hoisted(() => ({
|
|
5
|
+
handler: null,
|
|
6
|
+
}));
|
|
7
|
+
vi.mock("ink", async () => {
|
|
8
|
+
const actual = await vi.importActual("ink");
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
useInput: (handler, options) => {
|
|
12
|
+
// ReviewScreen calls useInput without options, so always capture
|
|
13
|
+
if (!options || options.isActive !== false) {
|
|
14
|
+
inputState.handler = handler;
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
import { ThemeContext } from "../../ThemeContext.js";
|
|
20
|
+
import { darkTheme } from "../../themes/dark.js";
|
|
21
|
+
import { ReviewScreen } from "../ReviewScreen.js";
|
|
22
|
+
const mockThemeValue = {
|
|
23
|
+
theme: darkTheme,
|
|
24
|
+
themeName: "AUQ dark",
|
|
25
|
+
cycleTheme: () => { },
|
|
26
|
+
};
|
|
27
|
+
function renderWithTheme(ui) {
|
|
28
|
+
return render(React.createElement(ThemeContext.Provider, { value: mockThemeValue }, ui));
|
|
29
|
+
}
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
cleanup();
|
|
32
|
+
inputState.handler = null;
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
});
|
|
35
|
+
describe("ReviewScreen keyboard handling", () => {
|
|
36
|
+
const sampleQuestions = [
|
|
37
|
+
{
|
|
38
|
+
title: "Q1",
|
|
39
|
+
prompt: "Choose an option",
|
|
40
|
+
options: [{ label: "Option A" }, { label: "Option B" }],
|
|
41
|
+
multiSelect: false,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
const sampleAnswers = new Map([
|
|
45
|
+
[0, { selectedOption: "Option A" }],
|
|
46
|
+
]);
|
|
47
|
+
function renderReview(overrides = {}) {
|
|
48
|
+
const props = {
|
|
49
|
+
questions: sampleQuestions,
|
|
50
|
+
answers: sampleAnswers,
|
|
51
|
+
elapsedLabel: "5s",
|
|
52
|
+
sessionId: "test-session-1",
|
|
53
|
+
onConfirm: vi.fn(),
|
|
54
|
+
onGoBack: vi.fn(),
|
|
55
|
+
isSubmitting: false,
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
const instance = renderWithTheme(React.createElement(ReviewScreen, { ...props }));
|
|
59
|
+
return { instance, ...props };
|
|
60
|
+
}
|
|
61
|
+
it("Enter submits answers", async () => {
|
|
62
|
+
const { onConfirm } = renderReview();
|
|
63
|
+
expect(inputState.handler).not.toBeNull();
|
|
64
|
+
inputState.handler("", { return: true });
|
|
65
|
+
await Promise.resolve();
|
|
66
|
+
expect(onConfirm).toHaveBeenCalledTimes(1);
|
|
67
|
+
});
|
|
68
|
+
it("n key goes back", async () => {
|
|
69
|
+
const { onGoBack } = renderReview();
|
|
70
|
+
expect(inputState.handler).not.toBeNull();
|
|
71
|
+
inputState.handler("n", {});
|
|
72
|
+
await Promise.resolve();
|
|
73
|
+
expect(onGoBack).toHaveBeenCalledTimes(1);
|
|
74
|
+
});
|
|
75
|
+
it("N key (uppercase) goes back (case-insensitive)", async () => {
|
|
76
|
+
const { onGoBack } = renderReview();
|
|
77
|
+
expect(inputState.handler).not.toBeNull();
|
|
78
|
+
inputState.handler("N", {});
|
|
79
|
+
await Promise.resolve();
|
|
80
|
+
expect(onGoBack).toHaveBeenCalledTimes(1);
|
|
81
|
+
});
|
|
82
|
+
it("Enter does not fire while submitting", async () => {
|
|
83
|
+
const { onConfirm } = renderReview({ isSubmitting: true });
|
|
84
|
+
expect(inputState.handler).not.toBeNull();
|
|
85
|
+
inputState.handler("", { return: true });
|
|
86
|
+
await Promise.resolve();
|
|
87
|
+
expect(onConfirm).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cleanup, render } from "ink-testing-library";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { ConfigProvider } from "../../ConfigContext.js";
|
|
5
|
+
import { ThemeContext } from "../../ThemeContext.js";
|
|
6
|
+
import { darkTheme } from "../../themes/dark.js";
|
|
7
|
+
import { StepperView } from "../StepperView.js";
|
|
8
|
+
const mockThemeValue = {
|
|
9
|
+
theme: darkTheme,
|
|
10
|
+
themeName: "AUQ dark",
|
|
11
|
+
cycleTheme: () => { },
|
|
12
|
+
};
|
|
13
|
+
const sessionRequest = {
|
|
14
|
+
sessionId: "kbd-test-session",
|
|
15
|
+
status: "pending",
|
|
16
|
+
timestamp: new Date().toISOString(),
|
|
17
|
+
callId: "call-kbd-1",
|
|
18
|
+
questions: [
|
|
19
|
+
{
|
|
20
|
+
title: "Q1",
|
|
21
|
+
prompt: "First question?",
|
|
22
|
+
options: [{ label: "A" }, { label: "B" }],
|
|
23
|
+
multiSelect: false,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
title: "Q2",
|
|
27
|
+
prompt: "Second question?",
|
|
28
|
+
options: [{ label: "C" }, { label: "D" }],
|
|
29
|
+
multiSelect: false,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
title: "Q3",
|
|
33
|
+
prompt: "Third question?",
|
|
34
|
+
options: [{ label: "E" }, { label: "F" }],
|
|
35
|
+
multiSelect: false,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
function renderStepper(props = {}) {
|
|
40
|
+
return render(React.createElement(ThemeContext.Provider, { value: mockThemeValue },
|
|
41
|
+
React.createElement(ConfigProvider, null,
|
|
42
|
+
React.createElement(StepperView, { sessionId: sessionRequest.sessionId, sessionRequest: sessionRequest, ...props }))));
|
|
43
|
+
}
|
|
44
|
+
function getOutput(frame) {
|
|
45
|
+
return (frame ?? "").replace(/\x1b\[[0-9;]*m/g, "").replace(/\r/g, "");
|
|
46
|
+
}
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
cleanup();
|
|
49
|
+
vi.restoreAllMocks();
|
|
50
|
+
});
|
|
51
|
+
describe("StepperView keyboard shortcuts", () => {
|
|
52
|
+
it("Escape shows rejection confirmation dialog", async () => {
|
|
53
|
+
const instance = renderStepper();
|
|
54
|
+
// Verify we start on question 1
|
|
55
|
+
let output = getOutput(instance.lastFrame());
|
|
56
|
+
expect(output).toContain("First question?");
|
|
57
|
+
// Press Escape
|
|
58
|
+
instance.stdin.write("\x1b");
|
|
59
|
+
await vi.waitFor(() => {
|
|
60
|
+
const out = getOutput(instance.lastFrame());
|
|
61
|
+
expect(out).toContain("Reject");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
it("Tab navigates to next question", async () => {
|
|
65
|
+
const instance = renderStepper();
|
|
66
|
+
// Verify starting on question 1
|
|
67
|
+
let output = getOutput(instance.lastFrame());
|
|
68
|
+
expect(output).toContain("First question?");
|
|
69
|
+
// Press Tab to advance to Q2
|
|
70
|
+
instance.stdin.write("\t");
|
|
71
|
+
await vi.waitFor(() => {
|
|
72
|
+
const out = getOutput(instance.lastFrame());
|
|
73
|
+
expect(out).toContain("Second question?");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
it("Shift+Tab navigates to previous question", async () => {
|
|
77
|
+
const onStateSnapshot = vi.fn();
|
|
78
|
+
const instance = renderStepper({ onStateSnapshot });
|
|
79
|
+
// First advance to Q2 with Tab
|
|
80
|
+
instance.stdin.write("\t");
|
|
81
|
+
await vi.waitFor(() => {
|
|
82
|
+
const out = getOutput(instance.lastFrame());
|
|
83
|
+
expect(out).toContain("Second question?");
|
|
84
|
+
});
|
|
85
|
+
// Press Shift+Tab (escape sequence for shift-tab in ink)
|
|
86
|
+
instance.stdin.write("\x1b[Z");
|
|
87
|
+
await vi.waitFor(() => {
|
|
88
|
+
const out = getOutput(instance.lastFrame());
|
|
89
|
+
expect(out).toContain("First question?");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
it("Tab does not navigate past the last question", async () => {
|
|
93
|
+
const instance = renderStepper();
|
|
94
|
+
// Navigate to Q3 (last question)
|
|
95
|
+
instance.stdin.write("\t"); // Q1 -> Q2
|
|
96
|
+
await vi.waitFor(() => {
|
|
97
|
+
const out = getOutput(instance.lastFrame());
|
|
98
|
+
expect(out).toContain("Second question?");
|
|
99
|
+
});
|
|
100
|
+
instance.stdin.write("\t"); // Q2 -> Q3
|
|
101
|
+
await vi.waitFor(() => {
|
|
102
|
+
const out = getOutput(instance.lastFrame());
|
|
103
|
+
expect(out).toContain("Third question?");
|
|
104
|
+
});
|
|
105
|
+
// Tab again should stay on Q3 (clamped)
|
|
106
|
+
instance.stdin.write("\t");
|
|
107
|
+
await vi.waitFor(() => {
|
|
108
|
+
const out = getOutput(instance.lastFrame());
|
|
109
|
+
expect(out).toContain("Third question?");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
it("Shift+Tab does not navigate before the first question", async () => {
|
|
113
|
+
const instance = renderStepper();
|
|
114
|
+
// Verify on Q1
|
|
115
|
+
let output = getOutput(instance.lastFrame());
|
|
116
|
+
expect(output).toContain("First question?");
|
|
117
|
+
// Press Shift+Tab at Q1 — should remain on Q1
|
|
118
|
+
instance.stdin.write("\x1b[Z");
|
|
119
|
+
// Allow state to settle
|
|
120
|
+
await vi.waitFor(() => {
|
|
121
|
+
const out = getOutput(instance.lastFrame());
|
|
122
|
+
expect(out).toContain("First question?");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
it("emits onProgress when Tab advances question", async () => {
|
|
126
|
+
const onProgress = vi.fn();
|
|
127
|
+
const instance = renderStepper({ onProgress });
|
|
128
|
+
// Tab to advance from Q1 to Q2
|
|
129
|
+
instance.stdin.write("\t");
|
|
130
|
+
await vi.waitFor(() => {
|
|
131
|
+
// onProgress should be called with (answered=1, total=3)
|
|
132
|
+
expect(onProgress).toHaveBeenCalledWith(1, 3);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cleanup, render } from "ink-testing-library";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
const inputState = vi.hoisted(() => ({
|
|
5
|
+
handler: null,
|
|
6
|
+
}));
|
|
7
|
+
vi.mock("ink", async () => {
|
|
8
|
+
const actual = await vi.importActual("ink");
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
useInput: (handler, options) => {
|
|
12
|
+
// WaitingScreen calls useInput without options, so always capture
|
|
13
|
+
if (!options || options.isActive !== false) {
|
|
14
|
+
inputState.handler = handler;
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
// Mock AnimatedGradient to avoid timer/animation side effects
|
|
20
|
+
vi.mock("../AnimatedGradient.js", () => ({
|
|
21
|
+
AnimatedGradient: ({ text }) => React.createElement("ink-text", null, text),
|
|
22
|
+
}));
|
|
23
|
+
import { ThemeContext } from "../../ThemeContext.js";
|
|
24
|
+
import { darkTheme } from "../../themes/dark.js";
|
|
25
|
+
import { WaitingScreen } from "../WaitingScreen.js";
|
|
26
|
+
const mockThemeValue = {
|
|
27
|
+
theme: darkTheme,
|
|
28
|
+
themeName: "AUQ dark",
|
|
29
|
+
cycleTheme: () => { },
|
|
30
|
+
};
|
|
31
|
+
function renderWithTheme(ui) {
|
|
32
|
+
return render(React.createElement(ThemeContext.Provider, { value: mockThemeValue }, ui));
|
|
33
|
+
}
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
cleanup();
|
|
36
|
+
inputState.handler = null;
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
});
|
|
39
|
+
describe("WaitingScreen keyboard handling", () => {
|
|
40
|
+
it("q key triggers quit", async () => {
|
|
41
|
+
const exitSpy = vi
|
|
42
|
+
.spyOn(process, "exit")
|
|
43
|
+
.mockImplementation((() => { }));
|
|
44
|
+
renderWithTheme(React.createElement(WaitingScreen, { queueCount: 0 }));
|
|
45
|
+
expect(inputState.handler).not.toBeNull();
|
|
46
|
+
inputState.handler("q", {});
|
|
47
|
+
await Promise.resolve();
|
|
48
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
49
|
+
});
|
|
50
|
+
it("Q key (uppercase) triggers quit (case-insensitive)", async () => {
|
|
51
|
+
const exitSpy = vi
|
|
52
|
+
.spyOn(process, "exit")
|
|
53
|
+
.mockImplementation((() => { }));
|
|
54
|
+
renderWithTheme(React.createElement(WaitingScreen, { queueCount: 0 }));
|
|
55
|
+
expect(inputState.handler).not.toBeNull();
|
|
56
|
+
inputState.handler("Q", {});
|
|
57
|
+
await Promise.resolve();
|
|
58
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Keybinding constants for the TUI application
|
|
2
|
+
// All keyboard shortcut definitions are centralized here.
|
|
3
|
+
export const KEYS = {
|
|
4
|
+
// Session switching (bare keys, no Ctrl)
|
|
5
|
+
SESSION_NEXT: "]",
|
|
6
|
+
SESSION_PREV: "[",
|
|
7
|
+
SESSION_JUMP_MIN: 1,
|
|
8
|
+
SESSION_JUMP_MAX: 9,
|
|
9
|
+
// Question navigation
|
|
10
|
+
RECOMMEND: "r",
|
|
11
|
+
QUICK_SUBMIT: "r", // used with key.ctrl
|
|
12
|
+
// Theme
|
|
13
|
+
THEME_CYCLE: "t", // used with key.ctrl
|
|
14
|
+
// Confirmation shortcuts
|
|
15
|
+
CONFIRM_YES: /^[yY]$/,
|
|
16
|
+
CONFIRM_NO: /^[nN]$/,
|
|
17
|
+
// Review
|
|
18
|
+
GO_BACK: /^[nN]$/,
|
|
19
|
+
// Waiting
|
|
20
|
+
QUIT: /^[qQ]$/,
|
|
21
|
+
};
|
|
22
|
+
// Display labels for Footer keybinding hints
|
|
23
|
+
export const KEY_LABELS = {
|
|
24
|
+
SESSION_SWITCH: "]/[",
|
|
25
|
+
SESSION_LIST: "Ctrl+S",
|
|
26
|
+
QUICK_SUBMIT: "Ctrl+R",
|
|
27
|
+
RECOMMEND: "R",
|
|
28
|
+
THEME: "Ctrl+T",
|
|
29
|
+
NAVIGATE_QUESTIONS: "←→",
|
|
30
|
+
NAVIGATE_QUESTIONS_TAB: "Tab/S+Tab",
|
|
31
|
+
NAVIGATE_OPTIONS: "↑↓",
|
|
32
|
+
SELECT: "Space",
|
|
33
|
+
SELECT_NEXT: "Enter",
|
|
34
|
+
NEXT: "Enter",
|
|
35
|
+
CURSOR: "←→",
|
|
36
|
+
NEWLINE: "Enter",
|
|
37
|
+
REJECT: "Esc",
|
|
38
|
+
BACK: "n",
|
|
39
|
+
SUBMIT: "Enter",
|
|
40
|
+
};
|