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.
- 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 +81 -9
- 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 +74 -9
- 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
|
@@ -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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
};
|