auq-mcp-server 2.1.1 → 2.2.1
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/tui-app.js +199 -18
- package/dist/package.json +1 -1
- package/dist/src/i18n/locales/en.js +3 -0
- package/dist/src/i18n/locales/ko.js +3 -0
- package/dist/src/tui/components/Footer.js +6 -1
- package/dist/src/tui/components/OptionsList.js +16 -3
- package/dist/src/tui/components/QuestionDisplay.js +5 -9
- package/dist/src/tui/components/SessionDots.js +65 -0
- package/dist/src/tui/components/SessionPicker.js +159 -0
- package/dist/src/tui/components/StepperView.js +69 -17
- package/dist/src/tui/components/__tests__/SessionDots.test.js +92 -0
- package/dist/src/tui/components/__tests__/SessionPicker.test.js +125 -0
- package/dist/src/tui/components/__tests__/StepperView.state.test.js +101 -0
- package/dist/src/tui/themes/catppuccin-latte.js +18 -0
- package/dist/src/tui/themes/catppuccin-mocha.js +18 -0
- package/dist/src/tui/themes/dark.js +18 -0
- package/dist/src/tui/themes/dracula.js +18 -0
- package/dist/src/tui/themes/github-dark.js +18 -0
- package/dist/src/tui/themes/github-light.js +18 -0
- package/dist/src/tui/themes/gruvbox-dark.js +18 -0
- package/dist/src/tui/themes/gruvbox-light.js +18 -0
- package/dist/src/tui/themes/light.js +18 -0
- package/dist/src/tui/themes/monokai.js +18 -0
- package/dist/src/tui/themes/nord.js +18 -0
- package/dist/src/tui/themes/one-dark.js +18 -0
- package/dist/src/tui/themes/rose-pine.js +18 -0
- package/dist/src/tui/themes/solarized-dark.js +18 -0
- package/dist/src/tui/themes/solarized-light.js +18 -0
- package/dist/src/tui/themes/tokyo-night.js +18 -0
- package/dist/src/tui/types.js +1 -0
- package/dist/src/tui/utils/__tests__/relativeTime.test.js +31 -0
- package/dist/src/tui/utils/__tests__/sessionSwitching.test.js +82 -0
- package/dist/src/tui/utils/relativeTime.js +24 -0
- package/dist/src/tui/utils/sessionSwitching.js +56 -0
- package/package.json +1 -1
|
@@ -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);
|
|
@@ -48,6 +49,8 @@ export const StepperView = ({ onComplete, onProgress, sessionId, sessionRequest,
|
|
|
48
49
|
const { stdout } = useStdout();
|
|
49
50
|
const terminalRows = stdout?.rows ?? 24;
|
|
50
51
|
const [isOverflowing, setIsOverflowing] = useState(false);
|
|
52
|
+
const isOverflowingRef = useRef(isOverflowing);
|
|
53
|
+
isOverflowingRef.current = isOverflowing;
|
|
51
54
|
// Report progress when question index changes
|
|
52
55
|
useEffect(() => {
|
|
53
56
|
if (onProgress) {
|
|
@@ -135,6 +138,7 @@ export const StepperView = ({ onComplete, onProgress, sessionId, sessionRequest,
|
|
|
135
138
|
};
|
|
136
139
|
// Track mount status to avoid state updates after unmount
|
|
137
140
|
const isMountedRef = useRef(true);
|
|
141
|
+
const skipSnapshotRef = useRef(true);
|
|
138
142
|
useEffect(() => {
|
|
139
143
|
isMountedRef.current = true;
|
|
140
144
|
return () => {
|
|
@@ -143,28 +147,74 @@ export const StepperView = ({ onComplete, onProgress, sessionId, sessionRequest,
|
|
|
143
147
|
}, []);
|
|
144
148
|
// Reset internal stepper state when the session changes (safety in case component isn't remounted)
|
|
145
149
|
useEffect(() => {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
150
|
+
const maxQuestionIndex = Math.max(0, sessionRequest.questions.length - 1);
|
|
151
|
+
if (initialState) {
|
|
152
|
+
const hydratedQuestionIndex = Math.min(Math.max(initialState.currentQuestionIndex, 0), maxQuestionIndex);
|
|
153
|
+
const hydratedQuestion = sessionRequest.questions[hydratedQuestionIndex];
|
|
154
|
+
const maxFocusedOptionIndex = (hydratedQuestion?.options.length ?? 0) + 1;
|
|
155
|
+
setCurrentQuestionIndex(hydratedQuestionIndex);
|
|
156
|
+
setAnswers(new Map(initialState.answers));
|
|
157
|
+
setElaborateMarks(new Map(initialState.elaborateMarks));
|
|
158
|
+
setFocusContext(initialState.focusContext);
|
|
159
|
+
setFocusedOptionIndex(Math.min(Math.max(initialState.focusedOptionIndex, 0), Math.max(0, maxFocusedOptionIndex)));
|
|
160
|
+
setShowReview(initialState.showReview);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
setCurrentQuestionIndex(0);
|
|
164
|
+
setAnswers(new Map());
|
|
165
|
+
setElaborateMarks(new Map());
|
|
166
|
+
setFocusContext("option");
|
|
167
|
+
setFocusedOptionIndex(0);
|
|
168
|
+
setShowReview(false);
|
|
169
|
+
}
|
|
149
170
|
setSubmitting(false);
|
|
150
171
|
setShowRejectionConfirm(false);
|
|
151
172
|
setElapsedSeconds(0);
|
|
152
|
-
|
|
173
|
+
skipSnapshotRef.current = true;
|
|
153
174
|
// Compute session-level recommended flag: true if ANY question has recommended options
|
|
154
175
|
const anyHasRecommended = sessionRequest.questions.some((question) => question.options.some((opt) => isRecommendedOption(opt.label)));
|
|
155
176
|
setHasAnyRecommendedInSession(anyHasRecommended);
|
|
156
|
-
}, [sessionId, sessionRequest.questions]);
|
|
177
|
+
}, [initialState, sessionId, sessionRequest.questions]);
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (!onStateSnapshot) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (skipSnapshotRef.current) {
|
|
183
|
+
skipSnapshotRef.current = false;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
onStateSnapshot(sessionId, {
|
|
187
|
+
currentQuestionIndex,
|
|
188
|
+
answers: new Map(answers),
|
|
189
|
+
elaborateMarks: new Map(elaborateMarks),
|
|
190
|
+
focusContext,
|
|
191
|
+
focusedOptionIndex,
|
|
192
|
+
showReview,
|
|
193
|
+
});
|
|
194
|
+
}, [
|
|
195
|
+
answers,
|
|
196
|
+
currentQuestionIndex,
|
|
197
|
+
elaborateMarks,
|
|
198
|
+
focusContext,
|
|
199
|
+
focusedOptionIndex,
|
|
200
|
+
onStateSnapshot,
|
|
201
|
+
sessionId,
|
|
202
|
+
showReview,
|
|
203
|
+
]);
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
onFlowStateChange?.({ showReview, showRejectionConfirm });
|
|
206
|
+
}, [onFlowStateChange, showRejectionConfirm, showReview]);
|
|
157
207
|
// Update elapsed time since session creation
|
|
158
208
|
// IMPORTANT: Pause when content overflows terminal to prevent scroll-snapping
|
|
159
209
|
useEffect(() => {
|
|
160
|
-
if (isOverflowing)
|
|
161
|
-
return;
|
|
162
210
|
const timer = setInterval(() => {
|
|
211
|
+
if (isOverflowingRef.current)
|
|
212
|
+
return;
|
|
163
213
|
const elapsed = Math.floor((Date.now() - sessionCreatedAt) / 1000);
|
|
164
214
|
setElapsedSeconds(elapsed >= 0 ? elapsed : 0);
|
|
165
215
|
}, 1000);
|
|
166
216
|
return () => clearInterval(timer);
|
|
167
|
-
}, [sessionCreatedAt
|
|
217
|
+
}, [sessionCreatedAt]);
|
|
168
218
|
// Detect overflow: estimate content height vs terminal rows
|
|
169
219
|
useEffect(() => {
|
|
170
220
|
const currentQ = sessionRequest.questions[safeIndex];
|
|
@@ -361,11 +411,13 @@ export const StepperView = ({ onComplete, onProgress, sessionId, sessionRequest,
|
|
|
361
411
|
}
|
|
362
412
|
return;
|
|
363
413
|
}
|
|
414
|
+
// Derive text-input state from both focusContext and focusedOptionIndex
|
|
415
|
+
// focusContext may lag by one render cycle (set via useEffect in OptionsList)
|
|
416
|
+
// focusedOptionIndex is set directly and always up-to-date
|
|
417
|
+
const isInTextInput = focusContext !== "option" ||
|
|
418
|
+
focusedOptionIndex >= currentQuestion.options.length;
|
|
364
419
|
// Tab/Shift+Tab: Global question navigation
|
|
365
|
-
|
|
366
|
-
if (key.tab &&
|
|
367
|
-
focusContext !== "custom-input" &&
|
|
368
|
-
focusContext !== "elaborate-input") {
|
|
420
|
+
if (key.tab && !isInTextInput) {
|
|
369
421
|
if (key.shift) {
|
|
370
422
|
setCurrentQuestionIndex((prev) => Math.max(0, prev - 1));
|
|
371
423
|
}
|
|
@@ -374,11 +426,11 @@ export const StepperView = ({ onComplete, onProgress, sessionId, sessionRequest,
|
|
|
374
426
|
}
|
|
375
427
|
return;
|
|
376
428
|
}
|
|
377
|
-
|
|
378
|
-
if (
|
|
429
|
+
// Left/Right arrow: question navigation (only when NOT in text input)
|
|
430
|
+
if (!isInTextInput && key.leftArrow && currentQuestionIndex > 0) {
|
|
379
431
|
setCurrentQuestionIndex((prev) => prev - 1);
|
|
380
432
|
}
|
|
381
|
-
if (
|
|
433
|
+
if (!isInTextInput &&
|
|
382
434
|
key.rightArrow &&
|
|
383
435
|
currentQuestionIndex < sessionRequest.questions.length - 1) {
|
|
384
436
|
setCurrentQuestionIndex((prev) => prev + 1);
|
|
@@ -434,5 +486,5 @@ export const StepperView = ({ onComplete, onProgress, sessionId, sessionRequest,
|
|
|
434
486
|
return (React.createElement(ReviewScreen, { isSubmitting: submitting, answers: answers, elapsedLabel: elapsedLabel, onConfirm: handleConfirm, onGoBack: handleGoBack, questions: sessionRequest.questions, sessionId: sessionId, elaborateMarks: elaborateMarks }));
|
|
435
487
|
}
|
|
436
488
|
// Show question display (default)
|
|
437
|
-
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 }));
|
|
489
|
+
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 }));
|
|
438
490
|
};
|
|
@@ -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
|
};
|
|
@@ -99,5 +99,23 @@ export const darkTheme = {
|
|
|
99
99
|
codeBlockText: "#E7EEF5",
|
|
100
100
|
codeBlockBorder: "#2A3238",
|
|
101
101
|
},
|
|
102
|
+
sessionDots: {
|
|
103
|
+
active: "#46D9FF",
|
|
104
|
+
answered: "#5AF78E",
|
|
105
|
+
inProgress: "#FFD36A",
|
|
106
|
+
untouched: "#A0AAB4",
|
|
107
|
+
number: "#E7EEF5",
|
|
108
|
+
activeNumber: "#46D9FF",
|
|
109
|
+
},
|
|
110
|
+
sessionPicker: {
|
|
111
|
+
border: "#46D9FF",
|
|
112
|
+
title: "#46D9FF",
|
|
113
|
+
rowText: "#E7EEF5",
|
|
114
|
+
rowDim: "#A0AAB4",
|
|
115
|
+
highlightBg: "#0F2417",
|
|
116
|
+
highlightFg: "#5AF78E",
|
|
117
|
+
activeMark: "#46D9FF",
|
|
118
|
+
progress: "#46D9FF",
|
|
119
|
+
},
|
|
102
120
|
},
|
|
103
121
|
};
|
|
@@ -98,5 +98,23 @@ export const draculaTheme = {
|
|
|
98
98
|
codeBlockText: "#f8f8f2",
|
|
99
99
|
codeBlockBorder: "#44475a",
|
|
100
100
|
},
|
|
101
|
+
sessionDots: {
|
|
102
|
+
active: "#bd93f9",
|
|
103
|
+
answered: "#50fa7b",
|
|
104
|
+
inProgress: "#f1fa8c",
|
|
105
|
+
untouched: "#7C8BBE",
|
|
106
|
+
number: "#f8f8f2",
|
|
107
|
+
activeNumber: "#bd93f9",
|
|
108
|
+
},
|
|
109
|
+
sessionPicker: {
|
|
110
|
+
border: "#bd93f9",
|
|
111
|
+
title: "#bd93f9",
|
|
112
|
+
rowText: "#f8f8f2",
|
|
113
|
+
rowDim: "#7C8BBE",
|
|
114
|
+
highlightBg: "#44475a",
|
|
115
|
+
highlightFg: "#50fa7b",
|
|
116
|
+
activeMark: "#bd93f9",
|
|
117
|
+
progress: "#8be9fd",
|
|
118
|
+
},
|
|
101
119
|
},
|
|
102
120
|
};
|
|
@@ -97,5 +97,23 @@ export const githubDarkTheme = {
|
|
|
97
97
|
codeBlockText: "#c9d1d9",
|
|
98
98
|
codeBlockBorder: "#30363d",
|
|
99
99
|
},
|
|
100
|
+
sessionDots: {
|
|
101
|
+
active: "#58a6ff",
|
|
102
|
+
answered: "#3fb950",
|
|
103
|
+
inProgress: "#d29922",
|
|
104
|
+
untouched: "#a0a8b4",
|
|
105
|
+
number: "#c9d1d9",
|
|
106
|
+
activeNumber: "#58a6ff",
|
|
107
|
+
},
|
|
108
|
+
sessionPicker: {
|
|
109
|
+
border: "#58a6ff",
|
|
110
|
+
title: "#58a6ff",
|
|
111
|
+
rowText: "#c9d1d9",
|
|
112
|
+
rowDim: "#a0a8b4",
|
|
113
|
+
highlightBg: "#0d1117",
|
|
114
|
+
highlightFg: "#3fb950",
|
|
115
|
+
activeMark: "#58a6ff",
|
|
116
|
+
progress: "#58a6ff",
|
|
117
|
+
},
|
|
100
118
|
},
|
|
101
119
|
};
|
|
@@ -97,5 +97,23 @@ export const githubLightTheme = {
|
|
|
97
97
|
codeBlockText: "#24292F",
|
|
98
98
|
codeBlockBorder: "#D0D7DE",
|
|
99
99
|
},
|
|
100
|
+
sessionDots: {
|
|
101
|
+
active: "#0969DA",
|
|
102
|
+
answered: "#1A7F37",
|
|
103
|
+
inProgress: "#9A6700",
|
|
104
|
+
untouched: "#6E7781",
|
|
105
|
+
number: "#24292F",
|
|
106
|
+
activeNumber: "#0969DA",
|
|
107
|
+
},
|
|
108
|
+
sessionPicker: {
|
|
109
|
+
border: "#0969DA",
|
|
110
|
+
title: "#0969DA",
|
|
111
|
+
rowText: "#24292F",
|
|
112
|
+
rowDim: "#6E7781",
|
|
113
|
+
highlightBg: "#DAFBE1",
|
|
114
|
+
highlightFg: "#1A7F37",
|
|
115
|
+
activeMark: "#0969DA",
|
|
116
|
+
progress: "#0969DA",
|
|
117
|
+
},
|
|
100
118
|
},
|
|
101
119
|
};
|
|
@@ -98,5 +98,23 @@ export const gruvboxDarkTheme = {
|
|
|
98
98
|
codeBlockText: "#ebdbb2",
|
|
99
99
|
codeBlockBorder: "#3c3836",
|
|
100
100
|
},
|
|
101
|
+
sessionDots: {
|
|
102
|
+
active: "#458588",
|
|
103
|
+
answered: "#98971a",
|
|
104
|
+
inProgress: "#d79921",
|
|
105
|
+
untouched: "#a89984",
|
|
106
|
+
number: "#ebdbb2",
|
|
107
|
+
activeNumber: "#458588",
|
|
108
|
+
},
|
|
109
|
+
sessionPicker: {
|
|
110
|
+
border: "#458588",
|
|
111
|
+
title: "#458588",
|
|
112
|
+
rowText: "#ebdbb2",
|
|
113
|
+
rowDim: "#a89984",
|
|
114
|
+
highlightBg: "#3c3836",
|
|
115
|
+
highlightFg: "#98971a",
|
|
116
|
+
activeMark: "#458588",
|
|
117
|
+
progress: "#458588",
|
|
118
|
+
},
|
|
101
119
|
},
|
|
102
120
|
};
|
|
@@ -98,5 +98,23 @@ export const gruvboxLightTheme = {
|
|
|
98
98
|
codeBlockText: "#3c3836",
|
|
99
99
|
codeBlockBorder: "#d5c4a1",
|
|
100
100
|
},
|
|
101
|
+
sessionDots: {
|
|
102
|
+
active: "#076678",
|
|
103
|
+
answered: "#79740e",
|
|
104
|
+
inProgress: "#b57614",
|
|
105
|
+
untouched: "#a89984",
|
|
106
|
+
number: "#3c3836",
|
|
107
|
+
activeNumber: "#076678",
|
|
108
|
+
},
|
|
109
|
+
sessionPicker: {
|
|
110
|
+
border: "#076678",
|
|
111
|
+
title: "#076678",
|
|
112
|
+
rowText: "#3c3836",
|
|
113
|
+
rowDim: "#a89984",
|
|
114
|
+
highlightBg: "#ebdbb2",
|
|
115
|
+
highlightFg: "#79740e",
|
|
116
|
+
activeMark: "#076678",
|
|
117
|
+
progress: "#076678",
|
|
118
|
+
},
|
|
101
119
|
},
|
|
102
120
|
};
|