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 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 MCP clients)
21
- ask <json> Ask questions via CLI (alternative to MCP)
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
- ASK COMMAND:
28
- Use 'auq ask' when you need to ask the user questions during execution.
29
- This allows you to:
30
- 1. Gather user preferences or requirements
31
- 2. Clarify ambiguous instructions
32
- 3. Get decisions on implementation choices as you work
33
- 4. Offer choices to the user about what direction to take
34
-
35
- FEATURES:
36
- - Ask 1-4 structured questions via an interactive terminal interface
37
- - Each question includes 2-4 multiple-choice options with explanatory descriptions
38
- - Users can always provide custom free-text input as an alternative to predefined options
39
- - Single-select mode (default): User picks ONE option or provides custom text
40
- - Multi-select mode (multiSelect: true): User can select MULTIPLE options
41
-
42
- USAGE NOTES:
43
- - Provide a descriptive 'title' field (max 12 chars) for each question
44
- - Use multiSelect: true when choices are not mutually exclusive
45
- - Option labels should be concise (1-5 words)
46
- - To mark a recommended option, append '(recommended)' to its label
47
- - Don't include an 'Other' option - it's provided automatically
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", "options": [{"label": "TypeScript (recommended)"}, {"label": "Python"}], "multiSelect": false}]}'
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:
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auq-mcp-server",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "main": "dist/index.js",
5
5
  "bin": {
6
6
  "auq": "dist/bin/auq.js"
@@ -45,7 +45,7 @@ export const en = {
45
45
  },
46
46
  input: {
47
47
  customAnswerLabel: "Custom answer",
48
- customAnswerHint: "(Press Tab to enter custom answer)",
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 === "t") {
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) => (prev > 0 ? prev - 1 : options.length - 1));
41
+ setFocusedIndex((prev) => Math.max(0, prev - 1));
41
42
  }
42
43
  if (key.downArrow) {
43
- setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0));
44
+ setFocusedIndex((prev) => Math.min(options.length - 1, prev + 1));
44
45
  }
45
46
  // Enter key - select focused option
46
47
  if (key.return) {
47
48
  options[focusedIndex].action();
48
49
  }
49
50
  // Letter shortcuts
50
- if (input === "y" || input === "Y") {
51
+ if (KEYS.CONFIRM_YES.test(input)) {
51
52
  setShowReasonInput(true);
52
53
  }
53
- if (input === "n" || input === "N") {
54
+ if (KEYS.CONFIRM_NO.test(input)) {
54
55
  onCancel();
55
56
  }
56
57
  // Esc key - same as quit
@@ -2,6 +2,7 @@ import { Box, Text } from "ink";
2
2
  import React, { useEffect, useState } from "react";
3
3
  import { t } from "../../i18n/index.js";
4
4
  import { useTheme } from "../ThemeContext.js";
5
+ import { KEY_LABELS } from "../constants/keybindings.js";
5
6
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
7
  /**
7
8
  * Footer component - displays context-aware keybindings
@@ -23,58 +24,58 @@ export const Footer = ({ focusContext, multiSelect, isReviewScreen = false, show
23
24
  // Review screen mode
24
25
  if (isReviewScreen) {
25
26
  return [
26
- { key: "Enter", action: t("footer.submit") },
27
- { key: "n", action: t("footer.back") },
27
+ { key: KEY_LABELS.SUBMIT, action: t("footer.submit") },
28
+ { key: KEY_LABELS.BACK, action: t("footer.back") },
28
29
  ];
29
30
  }
30
31
  // Custom input focused
31
32
  if (focusContext === "custom-input") {
32
33
  return [
33
- { key: "↑↓", action: t("footer.options") },
34
- { key: "←→", action: t("footer.cursor") },
35
- { key: "Tab/S+Tab", action: t("footer.questions") },
36
- { key: "Enter", action: t("footer.newline") },
37
- { key: "Esc", action: t("footer.reject") },
34
+ { key: KEY_LABELS.NAVIGATE_OPTIONS, action: t("footer.options") },
35
+ { key: KEY_LABELS.CURSOR, action: t("footer.cursor") },
36
+ { key: KEY_LABELS.NAVIGATE_QUESTIONS_TAB, action: t("footer.questions") },
37
+ { key: KEY_LABELS.NEWLINE, action: t("footer.newline") },
38
+ { key: KEY_LABELS.REJECT, action: t("footer.reject") },
38
39
  ];
39
40
  }
40
41
  // Elaborate input focused (Enter skips, not newline)
41
42
  if (focusContext === "elaborate-input") {
42
43
  return [
43
- { key: "↑↓", action: t("footer.options") },
44
- { key: "←→", action: t("footer.cursor") },
44
+ { key: KEY_LABELS.NAVIGATE_OPTIONS, action: t("footer.options") },
45
+ { key: KEY_LABELS.CURSOR, action: t("footer.cursor") },
45
46
  { key: "Enter/Tab", action: t("footer.next") },
46
- { key: "Esc", action: t("footer.reject") },
47
+ { key: KEY_LABELS.REJECT, action: t("footer.reject") },
47
48
  ];
48
49
  }
49
50
  // Option focused
50
51
  if (focusContext === "option") {
51
52
  const bindings = [
52
- { key: "↑↓", action: t("footer.options") },
53
- { key: "←→", action: t("footer.questions") },
54
- { key: "Tab/S+Tab", action: t("footer.questions") },
53
+ { key: KEY_LABELS.NAVIGATE_OPTIONS, action: t("footer.options") },
54
+ { key: KEY_LABELS.NAVIGATE_QUESTIONS, action: t("footer.questions") },
55
+ { key: KEY_LABELS.NAVIGATE_QUESTIONS_TAB, action: t("footer.questions") },
55
56
  ];
56
57
  if (multiSelect) {
57
- bindings.push({ key: "Space", action: t("footer.toggle") });
58
- bindings.push({ key: "Enter", action: t("footer.next") });
58
+ bindings.push({ key: KEY_LABELS.SELECT, action: t("footer.toggle") });
59
+ bindings.push({ key: KEY_LABELS.NEXT, action: t("footer.next") });
59
60
  }
60
61
  else {
61
- bindings.push({ key: "Space", action: t("footer.select") });
62
- bindings.push({ key: "Enter", action: t("footer.selectNext") });
62
+ bindings.push({ key: KEY_LABELS.SELECT, action: t("footer.select") });
63
+ bindings.push({ key: KEY_LABELS.SELECT_NEXT, action: t("footer.selectNext") });
63
64
  }
64
65
  if (hasRecommendedOptions) {
65
- bindings.push({ key: "R", action: t("footer.recommended") });
66
+ bindings.push({ key: KEY_LABELS.RECOMMEND, action: t("footer.recommended") });
66
67
  }
67
68
  // Ctrl+R shows when ANY question in session has recommended (not just current)
68
69
  if (hasAnyRecommendedInSession) {
69
- bindings.push({ key: "Ctrl+R", action: t("footer.quickSubmit") });
70
+ bindings.push({ key: KEY_LABELS.QUICK_SUBMIT, action: t("footer.quickSubmit") });
70
71
  }
71
72
  if (showSessionSwitching) {
72
- bindings.push({ key: "Ctrl+]/[", action: t("footer.sessions") });
73
+ bindings.push({ key: KEY_LABELS.SESSION_SWITCH, action: t("footer.sessions") });
73
74
  bindings.push({ key: "1-9", action: t("footer.jump") });
74
- bindings.push({ key: "Ctrl+S", action: t("footer.list") });
75
+ bindings.push({ key: KEY_LABELS.SESSION_LIST, action: t("footer.list") });
75
76
  }
76
- bindings.push({ key: "Ctrl+T", action: t("footer.theme") });
77
- bindings.push({ key: "Esc", action: t("footer.reject") });
77
+ bindings.push({ key: KEY_LABELS.THEME, action: t("footer.theme") });
78
+ bindings.push({ key: KEY_LABELS.REJECT, action: t("footer.reject") });
78
79
  return bindings;
79
80
  }
80
81
  return [];
@@ -4,6 +4,7 @@ import { t } from "../../i18n/index.js";
4
4
  import { useTheme } from "../ThemeContext.js";
5
5
  import { Footer } from "./Footer.js";
6
6
  import { MarkdownPrompt } from "./MarkdownPrompt.js";
7
+ import { KEYS } from "../constants/keybindings.js";
7
8
  /**
8
9
  * ReviewScreen displays a summary of all answers for confirmation
9
10
  * User can press Enter to confirm and submit, or 'n' to go back and edit
@@ -32,7 +33,7 @@ export const ReviewScreen = ({ answers, elapsedLabel, onConfirm, onGoBack, quest
32
33
  });
33
34
  onConfirm(userAnswers);
34
35
  }
35
- if (input === "n") {
36
+ if (KEYS.GO_BACK.test(input)) {
36
37
  onGoBack();
37
38
  }
38
39
  });
@@ -2,6 +2,7 @@ import { Box, Text, useInput, useStdout } from "ink";
2
2
  import React, { useEffect, useState } from "react";
3
3
  import { useTheme } from "../ThemeContext.js";
4
4
  import { formatRelativeTime } from "../utils/relativeTime.js";
5
+ import { KEYS } from "../constants/keybindings.js";
5
6
  /* ------------------------------------------------------------------ */
6
7
  /* Helpers */
7
8
  /* ------------------------------------------------------------------ */
@@ -69,7 +70,7 @@ export const SessionPicker = ({ isOpen, sessions, activeIndex, sessionUIStates,
69
70
  else {
70
71
  // Direct number jump (1-9)
71
72
  const num = parseInt(input, 10);
72
- if (num >= 1 && num <= sessions.length) {
73
+ if (num >= KEYS.SESSION_JUMP_MIN && num <= Math.min(KEYS.SESSION_JUMP_MAX, sessions.length)) {
73
74
  onSelectIndex(num - 1);
74
75
  onClose();
75
76
  }
@@ -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() === "r" &&
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() === "r" &&
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 === "q") {
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auq-mcp-server",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "main": "dist/index.js",
5
5
  "bin": {
6
6
  "auq": "dist/bin/auq.js"