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.
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 +16 -3
  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 +69 -17
  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
@@ -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
- setCurrentQuestionIndex(0);
147
- setAnswers(new Map());
148
- setShowReview(false);
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
- setElaborateMarks(new Map());
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, isOverflowing]);
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
- // Skip when in custom-input or elaborate-input mode - MultiLineTextInput handles Tab via onSubmit
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
- const shouldNavigate = focusContext !== "custom-input" && focusContext !== "elaborate-input";
378
- if (shouldNavigate && key.leftArrow && currentQuestionIndex > 0) {
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 (shouldNavigate &&
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
  };