auq-mcp-server 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/bin/tui-app.js +199 -18
  2. package/dist/package.json +1 -1
  3. package/dist/src/i18n/locales/en.js +3 -0
  4. package/dist/src/i18n/locales/ko.js +3 -0
  5. package/dist/src/tui/components/Footer.js +6 -1
  6. package/dist/src/tui/components/OptionsList.js +81 -9
  7. package/dist/src/tui/components/QuestionDisplay.js +5 -9
  8. package/dist/src/tui/components/SessionDots.js +65 -0
  9. package/dist/src/tui/components/SessionPicker.js +159 -0
  10. package/dist/src/tui/components/StepperView.js +74 -9
  11. package/dist/src/tui/components/__tests__/SessionDots.test.js +92 -0
  12. package/dist/src/tui/components/__tests__/SessionPicker.test.js +125 -0
  13. package/dist/src/tui/components/__tests__/StepperView.state.test.js +101 -0
  14. package/dist/src/tui/themes/catppuccin-latte.js +18 -0
  15. package/dist/src/tui/themes/catppuccin-mocha.js +18 -0
  16. package/dist/src/tui/themes/dark.js +18 -0
  17. package/dist/src/tui/themes/dracula.js +18 -0
  18. package/dist/src/tui/themes/github-dark.js +18 -0
  19. package/dist/src/tui/themes/github-light.js +18 -0
  20. package/dist/src/tui/themes/gruvbox-dark.js +18 -0
  21. package/dist/src/tui/themes/gruvbox-light.js +18 -0
  22. package/dist/src/tui/themes/light.js +18 -0
  23. package/dist/src/tui/themes/monokai.js +18 -0
  24. package/dist/src/tui/themes/nord.js +18 -0
  25. package/dist/src/tui/themes/one-dark.js +18 -0
  26. package/dist/src/tui/themes/rose-pine.js +18 -0
  27. package/dist/src/tui/themes/solarized-dark.js +18 -0
  28. package/dist/src/tui/themes/solarized-light.js +18 -0
  29. package/dist/src/tui/themes/tokyo-night.js +18 -0
  30. package/dist/src/tui/types.js +1 -0
  31. package/dist/src/tui/utils/__tests__/relativeTime.test.js +31 -0
  32. package/dist/src/tui/utils/__tests__/sessionSwitching.test.js +82 -0
  33. package/dist/src/tui/utils/relativeTime.js +24 -0
  34. package/dist/src/tui/utils/sessionSwitching.js +56 -0
  35. package/package.json +1 -1
@@ -0,0 +1,159 @@
1
+ import { Box, Text, useInput, useStdout } from "ink";
2
+ import React, { useEffect, useState } from "react";
3
+ import { useTheme } from "../ThemeContext.js";
4
+ import { formatRelativeTime } from "../utils/relativeTime.js";
5
+ /* ------------------------------------------------------------------ */
6
+ /* Helpers */
7
+ /* ------------------------------------------------------------------ */
8
+ function countAnswered(answers) {
9
+ if (!answers)
10
+ return 0;
11
+ let count = 0;
12
+ for (const ans of answers.values()) {
13
+ if (ans.selectedOption || ans.customText) {
14
+ count++;
15
+ continue;
16
+ }
17
+ if (ans.selectedOptions && ans.selectedOptions.length > 0)
18
+ count++;
19
+ }
20
+ return count;
21
+ }
22
+ function truncate(text, max) {
23
+ if (text.length <= max)
24
+ return text;
25
+ return text.slice(0, max - 1) + "…";
26
+ }
27
+ /* ------------------------------------------------------------------ */
28
+ /* Component */
29
+ /* ------------------------------------------------------------------ */
30
+ /**
31
+ * SessionPicker — a modal overlay listing all queued sessions.
32
+ *
33
+ * Opened via Ctrl+S. Each row shows:
34
+ * {index}. {title} — {workDir} [{answered}/{total}] {age}
35
+ *
36
+ * Navigation:
37
+ * ↑ / ↓ : move highlight
38
+ * Enter : select highlighted session
39
+ * Esc : close without switching
40
+ */
41
+ export const SessionPicker = ({ isOpen, sessions, activeIndex, sessionUIStates, onSelectIndex, onClose, }) => {
42
+ const { theme } = useTheme();
43
+ const { stdout } = useStdout();
44
+ const termHeight = stdout?.rows ?? 24;
45
+ const termWidth = stdout?.columns ?? 80;
46
+ // ── Highlight state ──────────────────────────────────────────────
47
+ const [highlightIndex, setHighlightIndex] = useState(activeIndex);
48
+ // Reset highlight to active index each time the picker opens
49
+ useEffect(() => {
50
+ if (isOpen) {
51
+ setHighlightIndex(activeIndex);
52
+ }
53
+ }, [isOpen, activeIndex]);
54
+ // ── Keyboard handling (only active when overlay is open) ────────
55
+ useInput((input, key) => {
56
+ if (key.upArrow) {
57
+ setHighlightIndex((prev) => Math.max(0, prev - 1));
58
+ }
59
+ else if (key.downArrow) {
60
+ setHighlightIndex((prev) => Math.min(sessions.length - 1, prev + 1));
61
+ }
62
+ else if (key.return) {
63
+ onSelectIndex(highlightIndex);
64
+ onClose();
65
+ }
66
+ else if (key.escape) {
67
+ onClose();
68
+ }
69
+ else {
70
+ // Direct number jump (1-9)
71
+ const num = parseInt(input, 10);
72
+ if (num >= 1 && num <= sessions.length) {
73
+ onSelectIndex(num - 1);
74
+ onClose();
75
+ }
76
+ }
77
+ }, { isActive: isOpen });
78
+ // ── Render nothing when closed ──────────────────────────────────
79
+ if (!isOpen)
80
+ return null;
81
+ // ── Scrolling logic ─────────────────────────────────────────────
82
+ // Reserve lines for border (2), title (1), padding (2), footer hint (2)
83
+ const chromeLines = 7;
84
+ const maxVisibleRows = Math.max(1, termHeight - chromeLines);
85
+ const needsScroll = sessions.length > maxVisibleRows;
86
+ let scrollOffset = 0;
87
+ if (needsScroll) {
88
+ // Keep highlighted row centred in the visible window
89
+ scrollOffset = Math.max(0, Math.min(highlightIndex - Math.floor(maxVisibleRows / 2), sessions.length - maxVisibleRows));
90
+ }
91
+ const visibleSessions = sessions.slice(scrollOffset, scrollOffset + maxVisibleRows);
92
+ // ── Derive max label widths from terminal size ──────────────────
93
+ // Layout: " {idx}. {title} — {dir} [{x}/{y}] {age} "
94
+ // We cap the variable-width parts to fit a single line.
95
+ const innerWidth = Math.max(30, termWidth - 6); // 6 for border + padding
96
+ const fixedOverhead = 18; // " 1. " + " — " + " [x/y] " + " Xm ago"
97
+ const dynamicWidth = Math.max(10, innerWidth - fixedOverhead);
98
+ const titleMax = Math.max(6, Math.floor(dynamicWidth * 0.55));
99
+ const dirMax = Math.max(4, dynamicWidth - titleMax);
100
+ return (React.createElement(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center" },
101
+ React.createElement(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.components.sessionPicker.border, paddingX: 2, paddingY: 1, width: Math.min(innerWidth + 6, termWidth) },
102
+ React.createElement(Box, { justifyContent: "center", marginBottom: 1 },
103
+ React.createElement(Text, { bold: true, color: theme.components.sessionPicker.title }, "Switch Session"),
104
+ React.createElement(Text, { color: theme.components.sessionPicker.rowDim },
105
+ " ",
106
+ "(",
107
+ sessions.length,
108
+ " queued)")),
109
+ needsScroll && scrollOffset > 0 && (React.createElement(Box, { justifyContent: "center" },
110
+ React.createElement(Text, { color: theme.components.sessionPicker.rowDim }, "\u25B2 more"))),
111
+ visibleSessions.map((session, visibleIdx) => {
112
+ const realIdx = scrollOffset + visibleIdx;
113
+ const isHighlighted = realIdx === highlightIndex;
114
+ const isActive = realIdx === activeIndex;
115
+ const uiState = sessionUIStates[session.sessionId];
116
+ const questions = session.sessionRequest.questions;
117
+ const title = truncate(questions[0]?.title || "Untitled", titleMax);
118
+ const dir = truncate(session.sessionRequest.workingDirectory || "unknown", dirMax);
119
+ const total = questions.length;
120
+ const answered = countAnswered(uiState?.answers);
121
+ const age = formatRelativeTime(session.timestamp);
122
+ // Row colors
123
+ const rowBg = isHighlighted
124
+ ? theme.components.sessionPicker.highlightBg
125
+ : undefined;
126
+ const textColor = isHighlighted
127
+ ? theme.components.sessionPicker.highlightFg
128
+ : isActive
129
+ ? theme.components.sessionPicker.activeMark
130
+ : theme.components.sessionPicker.rowText;
131
+ // Progress color
132
+ const progressColor = answered === total && total > 0
133
+ ? theme.components.sessionPicker.progress
134
+ : answered > 0
135
+ ? theme.components.sessionPicker.progress
136
+ : theme.components.sessionPicker.rowDim;
137
+ return (React.createElement(Box, { key: session.sessionId },
138
+ React.createElement(Text, { backgroundColor: rowBg, bold: isHighlighted, color: textColor },
139
+ isActive ? "►" : " ",
140
+ " ",
141
+ realIdx + 1,
142
+ ". ",
143
+ title),
144
+ React.createElement(Text, { backgroundColor: rowBg, color: theme.components.sessionPicker.rowDim },
145
+ " — ",
146
+ dir),
147
+ React.createElement(Text, { backgroundColor: rowBg, color: progressColor },
148
+ " [",
149
+ answered,
150
+ "/",
151
+ total,
152
+ "]"),
153
+ React.createElement(Text, { backgroundColor: rowBg, color: theme.components.sessionPicker.rowDim, dimColor: true }, age)));
154
+ }),
155
+ needsScroll && scrollOffset + maxVisibleRows < sessions.length && (React.createElement(Box, { justifyContent: "center" },
156
+ React.createElement(Text, { color: theme.components.sessionPicker.rowDim }, "\u25BC more"))),
157
+ React.createElement(Box, { justifyContent: "center", marginTop: 1 },
158
+ React.createElement(Text, { color: theme.components.sessionPicker.rowDim, dimColor: true }, "\u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc close \u00B7 1-9 jump")))));
159
+ };
@@ -1,4 +1,4 @@
1
- import { Box, useApp, useInput } from "ink";
1
+ import { Box, 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";
@@ -14,7 +14,7 @@ import { ReviewScreen } from "./ReviewScreen.js";
14
14
  * StepperView orchestrates the question-answering flow
15
15
  * Manages state for current question, answers, and navigation
16
16
  */
17
- export const StepperView = ({ onComplete, onProgress, sessionId, sessionRequest, }) => {
17
+ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initialState, onStateSnapshot, onFlowStateChange, sessionId, sessionRequest, }) => {
18
18
  const { theme } = useTheme();
19
19
  const config = useConfig();
20
20
  const { exit } = useApp();
@@ -25,6 +25,7 @@ export const StepperView = ({ onComplete, onProgress, sessionId, sessionRequest,
25
25
  const [showRejectionConfirm, setShowRejectionConfirm] = useState(false);
26
26
  const [elapsedSeconds, setElapsedSeconds] = useState(0);
27
27
  const [focusContext, setFocusContext] = useState("option");
28
+ const [focusedOptionIndex, setFocusedOptionIndex] = useState(0);
28
29
  const [hasRecommendedOptions, setHasRecommendedOptions] = useState(false);
29
30
  // Session-level flag: true if ANY question in the session has recommended options
30
31
  const [hasAnyRecommendedInSession, setHasAnyRecommendedInSession] = useState(false);
@@ -44,6 +45,10 @@ export const StepperView = ({ onComplete, onProgress, sessionId, sessionRequest,
44
45
  .map((value) => value.toString().padStart(2, "0"))
45
46
  .join(":");
46
47
  }, [elapsedSeconds]);
48
+ // Detect if content overflows terminal height to pause periodic re-renders
49
+ const { stdout } = useStdout();
50
+ const terminalRows = stdout?.rows ?? 24;
51
+ const [isOverflowing, setIsOverflowing] = useState(false);
47
52
  // Report progress when question index changes
48
53
  useEffect(() => {
49
54
  if (onProgress) {
@@ -131,6 +136,7 @@ export const StepperView = ({ onComplete, onProgress, sessionId, sessionRequest,
131
136
  };
132
137
  // Track mount status to avoid state updates after unmount
133
138
  const isMountedRef = useRef(true);
139
+ const skipSnapshotRef = useRef(true);
134
140
  useEffect(() => {
135
141
  isMountedRef.current = true;
136
142
  return () => {
@@ -139,25 +145,84 @@ export const StepperView = ({ onComplete, onProgress, sessionId, sessionRequest,
139
145
  }, []);
140
146
  // Reset internal stepper state when the session changes (safety in case component isn't remounted)
141
147
  useEffect(() => {
142
- setCurrentQuestionIndex(0);
143
- setAnswers(new Map());
144
- setShowReview(false);
148
+ const maxQuestionIndex = Math.max(0, sessionRequest.questions.length - 1);
149
+ if (initialState) {
150
+ const hydratedQuestionIndex = Math.min(Math.max(initialState.currentQuestionIndex, 0), maxQuestionIndex);
151
+ const hydratedQuestion = sessionRequest.questions[hydratedQuestionIndex];
152
+ const maxFocusedOptionIndex = (hydratedQuestion?.options.length ?? 0) + 1;
153
+ setCurrentQuestionIndex(hydratedQuestionIndex);
154
+ setAnswers(new Map(initialState.answers));
155
+ setElaborateMarks(new Map(initialState.elaborateMarks));
156
+ setFocusContext(initialState.focusContext);
157
+ setFocusedOptionIndex(Math.min(Math.max(initialState.focusedOptionIndex, 0), Math.max(0, maxFocusedOptionIndex)));
158
+ setShowReview(initialState.showReview);
159
+ }
160
+ else {
161
+ setCurrentQuestionIndex(0);
162
+ setAnswers(new Map());
163
+ setElaborateMarks(new Map());
164
+ setFocusContext("option");
165
+ setFocusedOptionIndex(0);
166
+ setShowReview(false);
167
+ }
145
168
  setSubmitting(false);
146
169
  setShowRejectionConfirm(false);
147
170
  setElapsedSeconds(0);
148
- setElaborateMarks(new Map());
171
+ skipSnapshotRef.current = true;
149
172
  // Compute session-level recommended flag: true if ANY question has recommended options
150
173
  const anyHasRecommended = sessionRequest.questions.some((question) => question.options.some((opt) => isRecommendedOption(opt.label)));
151
174
  setHasAnyRecommendedInSession(anyHasRecommended);
152
- }, [sessionId, sessionRequest.questions]);
175
+ }, [initialState, sessionId, sessionRequest.questions]);
176
+ useEffect(() => {
177
+ if (!onStateSnapshot) {
178
+ return;
179
+ }
180
+ if (skipSnapshotRef.current) {
181
+ skipSnapshotRef.current = false;
182
+ return;
183
+ }
184
+ onStateSnapshot(sessionId, {
185
+ currentQuestionIndex,
186
+ answers: new Map(answers),
187
+ elaborateMarks: new Map(elaborateMarks),
188
+ focusContext,
189
+ focusedOptionIndex,
190
+ showReview,
191
+ });
192
+ }, [
193
+ answers,
194
+ currentQuestionIndex,
195
+ elaborateMarks,
196
+ focusContext,
197
+ focusedOptionIndex,
198
+ onStateSnapshot,
199
+ sessionId,
200
+ showReview,
201
+ ]);
202
+ useEffect(() => {
203
+ onFlowStateChange?.({ showReview, showRejectionConfirm });
204
+ }, [onFlowStateChange, showRejectionConfirm, showReview]);
153
205
  // Update elapsed time since session creation
206
+ // IMPORTANT: Pause when content overflows terminal to prevent scroll-snapping
154
207
  useEffect(() => {
208
+ if (isOverflowing)
209
+ return;
155
210
  const timer = setInterval(() => {
156
211
  const elapsed = Math.floor((Date.now() - sessionCreatedAt) / 1000);
157
212
  setElapsedSeconds(elapsed >= 0 ? elapsed : 0);
158
213
  }, 1000);
159
214
  return () => clearInterval(timer);
160
- }, [sessionCreatedAt]);
215
+ }, [sessionCreatedAt, isOverflowing]);
216
+ // Detect overflow: estimate content height vs terminal rows
217
+ useEffect(() => {
218
+ const currentQ = sessionRequest.questions[safeIndex];
219
+ const optionCount = currentQ?.options?.length ?? 0;
220
+ // Conservative estimate: header(2) + tabbar(3) + prompt(3) + options(2 each)
221
+ // + footer(2) + custom/elaborate(6) + padding(2)
222
+ const estimatedContentHeight = 2 + 3 + 3 + optionCount * 2 + 2 + 6 + 2;
223
+ const nextOverflow = estimatedContentHeight > terminalRows;
224
+ setIsOverflowing((prev) => (prev === nextOverflow ? prev : nextOverflow));
225
+ }, [safeIndex, sessionRequest.questions, terminalRows]);
161
226
  // Handle answer confirmation
162
227
  const handleConfirm = async (userAnswers) => {
163
228
  setSubmitting(true);
@@ -417,5 +482,5 @@ export const StepperView = ({ onComplete, onProgress, sessionId, sessionRequest,
417
482
  return (React.createElement(ReviewScreen, { isSubmitting: submitting, answers: answers, elapsedLabel: elapsedLabel, onConfirm: handleConfirm, onGoBack: handleGoBack, questions: sessionRequest.questions, sessionId: sessionId, elaborateMarks: elaborateMarks }));
418
483
  }
419
484
  // Show question display (default)
420
- return (React.createElement(QuestionDisplay, { currentQuestion: currentQuestion, currentQuestionIndex: currentQuestionIndex, customAnswer: currentAnswer?.customText, elapsedLabel: elapsedLabel, onAdvanceToNext: handleAdvanceToNext, onChangeCustomAnswer: handleChangeCustomAnswer, onSelectOption: handleSelectOption, onToggleOption: handleToggleOption, multiSelect: currentQuestion.multiSelect, questions: sessionRequest.questions, selectedOption: currentAnswer?.selectedOption, answers: answers, onFocusContextChange: setFocusContext, workingDirectory: sessionRequest.workingDirectory, onRecommendedDetected: setHasRecommendedOptions, hasRecommendedOptions: hasRecommendedOptions, hasAnyRecommendedInSession: hasAnyRecommendedInSession, elaborateMarks: elaborateMarks, onElaborateSelect: handleElaborateSelect, elaborateText: elaborateMarks.get(currentQuestionIndex) || "", onElaborateTextChange: handleElaborateTextChange }));
485
+ return (React.createElement(QuestionDisplay, { currentQuestion: currentQuestion, currentQuestionIndex: currentQuestionIndex, customAnswer: currentAnswer?.customText, elapsedLabel: elapsedLabel, onAdvanceToNext: handleAdvanceToNext, onChangeCustomAnswer: handleChangeCustomAnswer, onSelectOption: handleSelectOption, onToggleOption: handleToggleOption, multiSelect: currentQuestion.multiSelect, questions: sessionRequest.questions, selectedOption: currentAnswer?.selectedOption, answers: answers, focusContext: focusContext, onFocusContextChange: setFocusContext, focusedOptionIndex: focusedOptionIndex, onFocusedOptionIndexChange: setFocusedOptionIndex, workingDirectory: sessionRequest.workingDirectory, onRecommendedDetected: setHasRecommendedOptions, hasRecommendedOptions: hasRecommendedOptions, hasAnyRecommendedInSession: hasAnyRecommendedInSession, elaborateMarks: elaborateMarks, onElaborateSelect: handleElaborateSelect, elaborateText: elaborateMarks.get(currentQuestionIndex) || "", onElaborateTextChange: handleElaborateTextChange, showSessionSwitching: hasMultipleSessions && !showReview && !showRejectionConfirm }));
421
486
  };
@@ -0,0 +1,92 @@
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 { SessionDots } from "../SessionDots.js";
7
+ const mockThemeValue = {
8
+ theme: darkTheme,
9
+ themeName: "AUQ dark",
10
+ cycleTheme: () => { },
11
+ };
12
+ function renderWithTheme(ui) {
13
+ return render(React.createElement(ThemeContext.Provider, { value: mockThemeValue }, ui));
14
+ }
15
+ function getOutput(frame) {
16
+ return (frame ?? "").replace(/\x1b\[[0-9;]*m/g, "").replace(/\r/g, "");
17
+ }
18
+ function createSession(id) {
19
+ return {
20
+ sessionId: `test-id-${id}`,
21
+ sessionRequest: {
22
+ sessionId: `test-id-${id}`,
23
+ status: "pending",
24
+ timestamp: new Date("2026-01-01T00:00:00.000Z").toISOString(),
25
+ workingDirectory: `/test/${id}`,
26
+ questions: [
27
+ {
28
+ title: `Q${id}`,
29
+ prompt: "test?",
30
+ options: [{ label: "A" }],
31
+ multiSelect: false,
32
+ },
33
+ ],
34
+ },
35
+ timestamp: new Date("2026-01-01T00:00:00.000Z"),
36
+ };
37
+ }
38
+ afterEach(() => {
39
+ cleanup();
40
+ vi.restoreAllMocks();
41
+ });
42
+ describe("SessionDots", () => {
43
+ it("renders nothing when sessions length is less than 2", () => {
44
+ const instance = renderWithTheme(React.createElement(SessionDots, { sessions: [createSession(1)], activeIndex: 0, sessionUIStates: {} }));
45
+ expect(getOutput(instance.lastFrame())).toBe("");
46
+ });
47
+ it("renders numbered dots for each session", () => {
48
+ const sessions = [createSession(1), createSession(2), createSession(3)];
49
+ const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 1, sessionUIStates: {} }));
50
+ const output = getOutput(instance.lastFrame());
51
+ expect(output).toContain("1");
52
+ expect(output).toContain("2");
53
+ expect(output).toContain("3");
54
+ expect(output).toContain("●");
55
+ expect(output).toContain("○");
56
+ });
57
+ it("renders progress-state sessions with one active and remaining inactive dots", () => {
58
+ const sessions = [
59
+ createSession(1),
60
+ createSession(2),
61
+ createSession(3),
62
+ createSession(4),
63
+ ];
64
+ const answeredState = {
65
+ currentQuestionIndex: 0,
66
+ answers: new Map([[0, { selectedOption: "A" }]]),
67
+ elaborateMarks: new Map(),
68
+ focusContext: "option",
69
+ focusedOptionIndex: 0,
70
+ showReview: false,
71
+ };
72
+ const inProgressState = {
73
+ currentQuestionIndex: 0,
74
+ answers: new Map(),
75
+ elaborateMarks: new Map(),
76
+ focusContext: "option",
77
+ focusedOptionIndex: 0,
78
+ showReview: false,
79
+ };
80
+ const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 0, sessionUIStates: {
81
+ [sessions[1].sessionId]: answeredState,
82
+ [sessions[2].sessionId]: inProgressState,
83
+ } }));
84
+ const output = getOutput(instance.lastFrame());
85
+ expect((output.match(/●/g) ?? []).length).toBe(1);
86
+ expect((output.match(/○/g) ?? []).length).toBe(3);
87
+ expect(output).toContain("1");
88
+ expect(output).toContain("2");
89
+ expect(output).toContain("3");
90
+ expect(output).toContain("4");
91
+ });
92
+ });
@@ -0,0 +1,125 @@
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
+ if (options?.isActive) {
13
+ inputState.handler = handler;
14
+ }
15
+ },
16
+ };
17
+ });
18
+ import { ThemeContext } from "../../ThemeContext.js";
19
+ import { darkTheme } from "../../themes/dark.js";
20
+ import { SessionPicker, } from "../SessionPicker.js";
21
+ const mockThemeValue = {
22
+ theme: darkTheme,
23
+ themeName: "AUQ dark",
24
+ cycleTheme: () => { },
25
+ };
26
+ function renderWithTheme(ui) {
27
+ return render(React.createElement(ThemeContext.Provider, { value: mockThemeValue }, ui));
28
+ }
29
+ function getOutput(frame) {
30
+ return (frame ?? "").replace(/\x1b\[[0-9;]*m/g, "").replace(/\r/g, "");
31
+ }
32
+ function createSession(id) {
33
+ return {
34
+ sessionId: `picker-id-${id}`,
35
+ sessionRequest: {
36
+ sessionId: `picker-id-${id}`,
37
+ status: "pending",
38
+ timestamp: new Date("2026-01-01T00:00:00.000Z").toISOString(),
39
+ workingDirectory: `/work/dir-${id}`,
40
+ questions: [
41
+ {
42
+ title: `Title ${id}`,
43
+ prompt: "choose",
44
+ options: [{ label: "A" }],
45
+ multiSelect: false,
46
+ },
47
+ ],
48
+ },
49
+ timestamp: new Date("2026-01-01T00:00:00.000Z"),
50
+ };
51
+ }
52
+ afterEach(() => {
53
+ cleanup();
54
+ inputState.handler = null;
55
+ vi.restoreAllMocks();
56
+ });
57
+ describe("SessionPicker", () => {
58
+ it("renders nothing when isOpen is false", () => {
59
+ const instance = renderWithTheme(React.createElement(SessionPicker, { isOpen: false, sessions: [createSession(1), createSession(2)], activeIndex: 0, sessionUIStates: {}, onSelectIndex: () => { }, onClose: () => { } }));
60
+ expect(getOutput(instance.lastFrame())).toBe("");
61
+ });
62
+ it("renders session rows with number, title, directory, progress, and active marker", () => {
63
+ const sessions = [createSession(1), createSession(2)];
64
+ const answeredState = {
65
+ currentQuestionIndex: 0,
66
+ answers: new Map([[0, { selectedOption: "A" }]]),
67
+ elaborateMarks: new Map(),
68
+ focusContext: "option",
69
+ focusedOptionIndex: 0,
70
+ showReview: false,
71
+ };
72
+ const instance = renderWithTheme(React.createElement(SessionPicker, { isOpen: true, sessions: sessions, activeIndex: 0, sessionUIStates: { [sessions[0].sessionId]: answeredState }, onSelectIndex: () => { }, onClose: () => { } }));
73
+ const output = getOutput(instance.lastFrame());
74
+ expect(output).toContain("Switch Session");
75
+ expect(output).toContain("1. Title 1");
76
+ expect(output).toContain("2. Title 2");
77
+ expect(output).toContain("/work/dir-1");
78
+ expect(output).toContain("[1/1]");
79
+ expect(output).toContain("[0/1]");
80
+ expect(output).toContain("► 1.");
81
+ });
82
+ it("handles down arrow without immediate selection", async () => {
83
+ const sessions = [createSession(1), createSession(2), createSession(3)];
84
+ const onSelectIndex = vi.fn();
85
+ const onClose = vi.fn();
86
+ renderWithTheme(React.createElement(SessionPicker, { isOpen: true, sessions: sessions, activeIndex: 0, sessionUIStates: {}, onSelectIndex: onSelectIndex, onClose: onClose }));
87
+ expect(inputState.handler).not.toBeNull();
88
+ inputState.handler("", { downArrow: true });
89
+ await Promise.resolve();
90
+ expect(onSelectIndex).not.toHaveBeenCalled();
91
+ expect(onClose).not.toHaveBeenCalled();
92
+ });
93
+ it("selects the currently highlighted session on Enter", async () => {
94
+ const sessions = [createSession(1), createSession(2), createSession(3)];
95
+ const onSelectIndex = vi.fn();
96
+ const onClose = vi.fn();
97
+ renderWithTheme(React.createElement(SessionPicker, { isOpen: true, sessions: sessions, activeIndex: 0, sessionUIStates: {}, onSelectIndex: onSelectIndex, onClose: onClose }));
98
+ expect(inputState.handler).not.toBeNull();
99
+ inputState.handler("", { return: true });
100
+ await Promise.resolve();
101
+ expect(onSelectIndex).toHaveBeenCalledWith(0);
102
+ expect(onClose).toHaveBeenCalled();
103
+ });
104
+ it("supports direct number key selection", async () => {
105
+ const sessions = [createSession(1), createSession(2), createSession(3)];
106
+ const onSelectIndex = vi.fn();
107
+ const onClose = vi.fn();
108
+ renderWithTheme(React.createElement(SessionPicker, { isOpen: true, sessions: sessions, activeIndex: 0, sessionUIStates: {}, onSelectIndex: onSelectIndex, onClose: onClose }));
109
+ expect(inputState.handler).not.toBeNull();
110
+ inputState.handler("2", {});
111
+ await Promise.resolve();
112
+ expect(onSelectIndex).toHaveBeenCalledWith(1);
113
+ expect(onClose).toHaveBeenCalled();
114
+ });
115
+ it("calls onClose on Escape", async () => {
116
+ const onSelectIndex = vi.fn();
117
+ const onClose = vi.fn();
118
+ renderWithTheme(React.createElement(SessionPicker, { isOpen: true, sessions: [createSession(1), createSession(2)], activeIndex: 0, sessionUIStates: {}, onSelectIndex: onSelectIndex, onClose: onClose }));
119
+ expect(inputState.handler).not.toBeNull();
120
+ inputState.handler("", { escape: true });
121
+ await Promise.resolve();
122
+ expect(onClose).toHaveBeenCalledTimes(1);
123
+ expect(onSelectIndex).not.toHaveBeenCalled();
124
+ });
125
+ });
@@ -0,0 +1,101 @@
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: "session-1",
15
+ status: "pending",
16
+ timestamp: new Date().toISOString(),
17
+ callId: "call-1",
18
+ questions: [
19
+ {
20
+ title: "Language",
21
+ prompt: "Pick a language",
22
+ options: [{ label: "TypeScript" }, { label: "Python" }],
23
+ },
24
+ {
25
+ title: "Framework",
26
+ prompt: "Pick a framework",
27
+ options: [{ label: "React" }, { label: "Vue" }],
28
+ },
29
+ {
30
+ title: "Runtime",
31
+ prompt: "Pick a runtime",
32
+ options: [{ label: "Bun" }, { label: "Node.js" }],
33
+ },
34
+ ],
35
+ };
36
+ function renderStepper(props = {}) {
37
+ return render(React.createElement(ThemeContext.Provider, { value: mockThemeValue },
38
+ React.createElement(ConfigProvider, null,
39
+ React.createElement(StepperView, { sessionId: sessionRequest.sessionId, sessionRequest: sessionRequest, ...props }))));
40
+ }
41
+ function getOutput(frame) {
42
+ return (frame ?? "").replace(/\x1b\[[0-9;]*m/g, "").replace(/\r/g, "");
43
+ }
44
+ afterEach(() => {
45
+ cleanup();
46
+ vi.restoreAllMocks();
47
+ });
48
+ describe("StepperView SessionUIState boundary", () => {
49
+ it("hydrates from initialState and starts on the hydrated question", async () => {
50
+ const initialState = {
51
+ currentQuestionIndex: 1,
52
+ answers: new Map([[0, { selectedOption: "TypeScript" }]]),
53
+ elaborateMarks: new Map([[0, "Please elaborate"]]),
54
+ focusContext: "option",
55
+ focusedOptionIndex: 1,
56
+ showReview: false,
57
+ };
58
+ const instance = renderStepper({ initialState });
59
+ await vi.waitFor(() => {
60
+ const hydratedOutput = getOutput(instance.lastFrame());
61
+ expect(hydratedOutput).toContain("Pick a framework");
62
+ });
63
+ const output = getOutput(instance.lastFrame());
64
+ expect(output).not.toContain("Pick a language");
65
+ expect(output).toContain("1/3");
66
+ });
67
+ it("uses default state when no initialState is provided", () => {
68
+ const instance = renderStepper();
69
+ const output = getOutput(instance.lastFrame());
70
+ expect(output).toContain("Pick a language");
71
+ expect(output).not.toContain("Pick a framework");
72
+ expect(output).toContain("0/3");
73
+ });
74
+ it("emits snapshot after state change and skips initial mount emission", async () => {
75
+ const onStateSnapshot = vi.fn();
76
+ const instance = renderStepper({ onStateSnapshot });
77
+ expect(onStateSnapshot).not.toHaveBeenCalled();
78
+ instance.stdin.write("\t");
79
+ await vi.waitFor(() => {
80
+ expect(onStateSnapshot).toHaveBeenCalled();
81
+ });
82
+ const [sessionId, state] = onStateSnapshot.mock.lastCall;
83
+ expect(sessionId).toBe("session-1");
84
+ expect(state.currentQuestionIndex).toBe(1);
85
+ expect(state.focusContext).toBe("option");
86
+ expect(state.showReview).toBe(false);
87
+ expect(state.answers).toBeInstanceOf(Map);
88
+ expect(state.elaborateMarks).toBeInstanceOf(Map);
89
+ });
90
+ it("emits initial flow state via onFlowStateChange", async () => {
91
+ const onFlowStateChange = vi.fn();
92
+ renderStepper({ onFlowStateChange });
93
+ await vi.waitFor(() => {
94
+ expect(onFlowStateChange).toHaveBeenCalled();
95
+ });
96
+ expect(onFlowStateChange).toHaveBeenCalledWith({
97
+ showReview: false,
98
+ showRejectionConfirm: false,
99
+ });
100
+ });
101
+ });
@@ -98,5 +98,23 @@ export const catppuccinLatteTheme = {
98
98
  codeBlockText: "#4c4f69",
99
99
  codeBlockBorder: "#ccd0da",
100
100
  },
101
+ sessionDots: {
102
+ active: "#1e66f5",
103
+ answered: "#40a02b",
104
+ inProgress: "#df8e1d",
105
+ untouched: "#acb0c0",
106
+ number: "#4c4f69",
107
+ activeNumber: "#1e66f5",
108
+ },
109
+ sessionPicker: {
110
+ border: "#1e66f5",
111
+ title: "#1e66f5",
112
+ rowText: "#4c4f69",
113
+ rowDim: "#acb0c0",
114
+ highlightBg: "#ccd0da",
115
+ highlightFg: "#40a02b",
116
+ activeMark: "#1e66f5",
117
+ progress: "#04a5e5",
118
+ },
101
119
  },
102
120
  };
@@ -98,5 +98,23 @@ export const catppuccinMochaTheme = {
98
98
  codeBlockText: "#cdd6f4",
99
99
  codeBlockBorder: "#313244",
100
100
  },
101
+ sessionDots: {
102
+ active: "#89b4fa",
103
+ answered: "#a6e3a1",
104
+ inProgress: "#f9e2af",
105
+ untouched: "#8688a0",
106
+ number: "#cdd6f4",
107
+ activeNumber: "#89b4fa",
108
+ },
109
+ sessionPicker: {
110
+ border: "#89b4fa",
111
+ title: "#89b4fa",
112
+ rowText: "#cdd6f4",
113
+ rowDim: "#8688a0",
114
+ highlightBg: "#313244",
115
+ highlightFg: "#a6e3a1",
116
+ activeMark: "#89b4fa",
117
+ progress: "#89dceb",
118
+ },
101
119
  },
102
120
  };