auq-mcp-server 2.7.1 → 2.7.2
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/package.json +3 -3
- package/dist/src/i18n/types.js +0 -1
- package/dist/src/tui/shared/session-events.js +0 -1
- package/dist/src/tui/shared/themes/types.js +0 -1
- package/dist/src/tui/shared/types.js +0 -1
- package/dist/src/tui-opentui/ConfigContext.js +10 -0
- package/dist/src/tui-opentui/ThemeProvider.js +73 -0
- package/dist/src/tui-opentui/app.js +536 -0
- package/dist/src/tui-opentui/components/AnimatedGradient.js +56 -0
- package/dist/src/tui-opentui/components/ConfirmationDialog.js +89 -0
- package/dist/src/tui-opentui/components/CustomInput.js +25 -0
- package/dist/src/tui-opentui/components/ErrorBoundary.js +26 -0
- package/dist/src/tui-opentui/components/Footer.js +92 -0
- package/dist/src/tui-opentui/components/Header.js +46 -0
- package/dist/src/tui-opentui/components/MarkdownPrompt.js +13 -0
- package/dist/src/tui-opentui/components/OptionsList.js +258 -0
- package/dist/src/tui-opentui/components/QuestionDisplay.js +23 -0
- package/dist/src/tui-opentui/components/ReviewScreen.js +81 -0
- package/dist/src/tui-opentui/components/SessionDots.js +86 -0
- package/dist/src/tui-opentui/components/SessionPicker.js +162 -0
- package/dist/src/tui-opentui/components/SingleLineTextInput.js +9 -0
- package/dist/src/tui-opentui/components/StepperView.js +493 -0
- package/dist/src/tui-opentui/components/TabBar.js +79 -0
- package/dist/src/tui-opentui/components/ThemeIndicator.js +35 -0
- package/dist/src/tui-opentui/components/Toast.js +44 -0
- package/dist/src/tui-opentui/components/UpdateBadge.js +24 -0
- package/dist/src/tui-opentui/components/UpdateOverlay.js +162 -0
- package/dist/src/tui-opentui/components/WaitingScreen.js +44 -0
- package/dist/src/tui-opentui/hooks/useSessionWatcher.js +69 -0
- package/dist/src/tui-opentui/hooks/useTerminalDimensions.js +8 -0
- package/dist/src/tui-opentui/utils/syntaxStyle.js +64 -0
- package/dist/src/update/types.js +0 -1
- package/package.json +3 -3
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { useKeyboard } from "@opentui/react";
|
|
4
|
+
import { t } from "../../i18n/index.js";
|
|
5
|
+
import { ResponseFormatter } from "../../session/ResponseFormatter.js";
|
|
6
|
+
import { SessionManager } from "../../session/SessionManager.js";
|
|
7
|
+
import { getSessionDirectory } from "../../session/utils.js";
|
|
8
|
+
import { useTheme } from "../ThemeProvider.js";
|
|
9
|
+
import { useConfig } from "../ConfigContext.js";
|
|
10
|
+
import { useTerminalDimensions } from "../hooks/useTerminalDimensions.js";
|
|
11
|
+
import { isRecommendedOption } from "../../tui/shared/utils/recommended.js";
|
|
12
|
+
import { KEYS } from "../../tui/constants/keybindings.js";
|
|
13
|
+
import { ConfirmationDialog } from "./ConfirmationDialog.js";
|
|
14
|
+
import { QuestionDisplay } from "./QuestionDisplay.js";
|
|
15
|
+
import { ReviewScreen } from "./ReviewScreen.js";
|
|
16
|
+
/**
|
|
17
|
+
* StepperView orchestrates the question-answering flow.
|
|
18
|
+
* Manages state for current question, answers, and navigation.
|
|
19
|
+
*/
|
|
20
|
+
export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initialState, onStateSnapshot, onFlowStateChange, sessionId, sessionRequest, isAbandoned, onAbandonedCancel, }) => {
|
|
21
|
+
const { theme } = useTheme();
|
|
22
|
+
const config = useConfig();
|
|
23
|
+
const { height: terminalRows } = useTerminalDimensions();
|
|
24
|
+
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
|
25
|
+
const [answers, setAnswers] = useState(new Map());
|
|
26
|
+
const [showReview, setShowReview] = useState(false);
|
|
27
|
+
const [submitting, setSubmitting] = useState(false);
|
|
28
|
+
const [showRejectionConfirm, setShowRejectionConfirm] = useState(false);
|
|
29
|
+
const [showAbandonedConfirm, setShowAbandonedConfirm] = useState(false);
|
|
30
|
+
const [abandonedConfirmed, setAbandonedConfirmed] = useState(false);
|
|
31
|
+
const [abandonedFocusedIndex, setAbandonedFocusedIndex] = useState(0);
|
|
32
|
+
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
|
33
|
+
const [focusContext, setFocusContext] = useState("option");
|
|
34
|
+
const [focusedOptionIndex, setFocusedOptionIndex] = useState(0);
|
|
35
|
+
const [hasRecommendedOptions, setHasRecommendedOptions] = useState(false);
|
|
36
|
+
const [hasAnyRecommendedInSession, setHasAnyRecommendedInSession] = useState(false);
|
|
37
|
+
const [elaborateMarks, setElaborateMarks] = useState(new Map());
|
|
38
|
+
const safeIndex = Math.min(currentQuestionIndex, sessionRequest.questions.length - 1);
|
|
39
|
+
const currentQuestion = sessionRequest.questions[safeIndex];
|
|
40
|
+
const sessionCreatedAt = useMemo(() => {
|
|
41
|
+
const parsed = Date.parse(sessionRequest.timestamp);
|
|
42
|
+
return Number.isNaN(parsed) ? Date.now() : parsed;
|
|
43
|
+
}, [sessionRequest.timestamp]);
|
|
44
|
+
const elapsedLabel = useMemo(() => {
|
|
45
|
+
const hours = Math.floor(elapsedSeconds / 3600);
|
|
46
|
+
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
|
|
47
|
+
const seconds = elapsedSeconds % 60;
|
|
48
|
+
return [hours, minutes, seconds]
|
|
49
|
+
.map((value) => value.toString().padStart(2, "0"))
|
|
50
|
+
.join(":");
|
|
51
|
+
}, [elapsedSeconds]);
|
|
52
|
+
// Detect content overflow to pause periodic re-renders
|
|
53
|
+
const [isOverflowing, setIsOverflowing] = useState(false);
|
|
54
|
+
const isOverflowingRef = useRef(isOverflowing);
|
|
55
|
+
isOverflowingRef.current = isOverflowing;
|
|
56
|
+
// Report progress when question index changes
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (onProgress) {
|
|
59
|
+
const answered = showReview
|
|
60
|
+
? sessionRequest.questions.length
|
|
61
|
+
: currentQuestionIndex;
|
|
62
|
+
onProgress(answered, sessionRequest.questions.length);
|
|
63
|
+
}
|
|
64
|
+
}, [
|
|
65
|
+
currentQuestionIndex,
|
|
66
|
+
showReview,
|
|
67
|
+
sessionRequest.questions.length,
|
|
68
|
+
onProgress,
|
|
69
|
+
]);
|
|
70
|
+
// Reset focused option to first when switching between questions
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
setFocusedOptionIndex(0);
|
|
73
|
+
}, [currentQuestionIndex]);
|
|
74
|
+
// Handle option selection (single-select mode)
|
|
75
|
+
const handleSelectOption = (label) => {
|
|
76
|
+
setAnswers((prev) => {
|
|
77
|
+
const newAnswers = new Map(prev);
|
|
78
|
+
const existing = newAnswers.get(currentQuestionIndex) || {};
|
|
79
|
+
newAnswers.set(currentQuestionIndex, {
|
|
80
|
+
...existing,
|
|
81
|
+
selectedOption: label,
|
|
82
|
+
});
|
|
83
|
+
return newAnswers;
|
|
84
|
+
});
|
|
85
|
+
// Clear elaborate mark when selecting a regular option (single-select behavior)
|
|
86
|
+
setElaborateMarks((prev) => {
|
|
87
|
+
if (prev.has(currentQuestionIndex)) {
|
|
88
|
+
const newMarks = new Map(prev);
|
|
89
|
+
newMarks.delete(currentQuestionIndex);
|
|
90
|
+
return newMarks;
|
|
91
|
+
}
|
|
92
|
+
return prev;
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
const handleToggleOption = (label) => {
|
|
96
|
+
setAnswers((prev) => {
|
|
97
|
+
const newAnswers = new Map(prev);
|
|
98
|
+
const existing = newAnswers.get(currentQuestionIndex) || {};
|
|
99
|
+
const currentSelections = existing.selectedOptions || [];
|
|
100
|
+
const isAdding = !currentSelections.includes(label);
|
|
101
|
+
const newSelections = isAdding
|
|
102
|
+
? [...currentSelections, label]
|
|
103
|
+
: currentSelections.filter((l) => l !== label);
|
|
104
|
+
newAnswers.set(currentQuestionIndex, {
|
|
105
|
+
selectedOptions: newSelections,
|
|
106
|
+
customText: existing.customText,
|
|
107
|
+
});
|
|
108
|
+
return newAnswers;
|
|
109
|
+
});
|
|
110
|
+
// Clear elaboration when selecting a regular option (mutually exclusive)
|
|
111
|
+
const currentSelections = answers.get(currentQuestionIndex)?.selectedOptions || [];
|
|
112
|
+
const isAdding = !currentSelections.includes(label);
|
|
113
|
+
if (isAdding && elaborateMarks.has(currentQuestionIndex)) {
|
|
114
|
+
setElaborateMarks((prev) => {
|
|
115
|
+
const newMarks = new Map(prev);
|
|
116
|
+
newMarks.delete(currentQuestionIndex);
|
|
117
|
+
return newMarks;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
// Handle custom answer text
|
|
122
|
+
const handleChangeCustomAnswer = (text) => {
|
|
123
|
+
setAnswers((prev) => {
|
|
124
|
+
const newAnswers = new Map(prev);
|
|
125
|
+
const existing = newAnswers.get(currentQuestionIndex) || {};
|
|
126
|
+
newAnswers.set(currentQuestionIndex, {
|
|
127
|
+
...existing,
|
|
128
|
+
customText: text,
|
|
129
|
+
});
|
|
130
|
+
return newAnswers;
|
|
131
|
+
});
|
|
132
|
+
// Clear elaboration when typing custom text (mutually exclusive)
|
|
133
|
+
if (text.trim().length > 0 && elaborateMarks.has(currentQuestionIndex)) {
|
|
134
|
+
setElaborateMarks((prev) => {
|
|
135
|
+
const newMarks = new Map(prev);
|
|
136
|
+
newMarks.delete(currentQuestionIndex);
|
|
137
|
+
return newMarks;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
// Track mount status to avoid state updates after unmount
|
|
142
|
+
const isMountedRef = useRef(true);
|
|
143
|
+
const skipSnapshotRef = useRef(true);
|
|
144
|
+
const sessionIdRef = useRef(null);
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
isMountedRef.current = true;
|
|
147
|
+
return () => {
|
|
148
|
+
isMountedRef.current = false;
|
|
149
|
+
};
|
|
150
|
+
}, []);
|
|
151
|
+
// Reset internal stepper state when the session changes
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
// Only run full initialization when the session actually changes.
|
|
154
|
+
// When a snapshot is saved (cursor movement), initialState prop changes but
|
|
155
|
+
// sessionId stays the same — skip the reset to avoid resetting the timer.
|
|
156
|
+
if (sessionId === sessionIdRef.current)
|
|
157
|
+
return;
|
|
158
|
+
sessionIdRef.current = sessionId;
|
|
159
|
+
const maxQuestionIndex = Math.max(0, sessionRequest.questions.length - 1);
|
|
160
|
+
if (initialState) {
|
|
161
|
+
const hydratedQuestionIndex = Math.min(Math.max(initialState.currentQuestionIndex, 0), maxQuestionIndex);
|
|
162
|
+
const hydratedQuestion = sessionRequest.questions[hydratedQuestionIndex];
|
|
163
|
+
const maxFocusedOptionIndex = (hydratedQuestion?.options.length ?? 0) + 1;
|
|
164
|
+
setCurrentQuestionIndex(hydratedQuestionIndex);
|
|
165
|
+
setAnswers(new Map(initialState.answers));
|
|
166
|
+
setElaborateMarks(new Map(initialState.elaborateMarks));
|
|
167
|
+
setFocusContext(initialState.focusContext);
|
|
168
|
+
setFocusedOptionIndex(Math.min(Math.max(initialState.focusedOptionIndex, 0), Math.max(0, maxFocusedOptionIndex)));
|
|
169
|
+
setShowReview(initialState.showReview);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
setCurrentQuestionIndex(0);
|
|
173
|
+
setAnswers(new Map());
|
|
174
|
+
setElaborateMarks(new Map());
|
|
175
|
+
setFocusContext("option");
|
|
176
|
+
setFocusedOptionIndex(0);
|
|
177
|
+
setShowReview(false);
|
|
178
|
+
}
|
|
179
|
+
setSubmitting(false);
|
|
180
|
+
setShowRejectionConfirm(false);
|
|
181
|
+
setElapsedSeconds(0);
|
|
182
|
+
skipSnapshotRef.current = true;
|
|
183
|
+
// Compute session-level recommended flag
|
|
184
|
+
const anyHasRecommended = sessionRequest.questions.some((question) => question.options.some((opt) => isRecommendedOption(opt.label)));
|
|
185
|
+
setHasAnyRecommendedInSession(anyHasRecommended);
|
|
186
|
+
}, [initialState, sessionId, sessionRequest.questions]);
|
|
187
|
+
// Show abandoned confirmation when entering an abandoned session
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (isAbandoned && !abandonedConfirmed) {
|
|
190
|
+
setShowAbandonedConfirm(true);
|
|
191
|
+
setAbandonedFocusedIndex(0);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
setShowAbandonedConfirm(false);
|
|
195
|
+
}
|
|
196
|
+
}, [sessionId, isAbandoned, abandonedConfirmed]);
|
|
197
|
+
// Emit state snapshot on changes
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
if (!onStateSnapshot) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (skipSnapshotRef.current) {
|
|
203
|
+
skipSnapshotRef.current = false;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
onStateSnapshot(sessionId, {
|
|
207
|
+
currentQuestionIndex,
|
|
208
|
+
answers: new Map(answers),
|
|
209
|
+
elaborateMarks: new Map(elaborateMarks),
|
|
210
|
+
focusContext,
|
|
211
|
+
focusedOptionIndex,
|
|
212
|
+
showReview,
|
|
213
|
+
});
|
|
214
|
+
}, [
|
|
215
|
+
answers,
|
|
216
|
+
currentQuestionIndex,
|
|
217
|
+
elaborateMarks,
|
|
218
|
+
focusContext,
|
|
219
|
+
focusedOptionIndex,
|
|
220
|
+
onStateSnapshot,
|
|
221
|
+
sessionId,
|
|
222
|
+
showReview,
|
|
223
|
+
]);
|
|
224
|
+
// Emit flow state changes
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
onFlowStateChange?.({ showReview, showRejectionConfirm, showAbandonedConfirm });
|
|
227
|
+
}, [onFlowStateChange, showRejectionConfirm, showReview, showAbandonedConfirm]);
|
|
228
|
+
// Update elapsed time since session creation
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
const timer = setInterval(() => {
|
|
231
|
+
if (isOverflowingRef.current)
|
|
232
|
+
return;
|
|
233
|
+
const elapsed = Math.floor((Date.now() - sessionCreatedAt) / 1000);
|
|
234
|
+
setElapsedSeconds(elapsed >= 0 ? elapsed : 0);
|
|
235
|
+
}, 1000);
|
|
236
|
+
return () => clearInterval(timer);
|
|
237
|
+
}, [sessionCreatedAt]);
|
|
238
|
+
// Detect overflow: estimate content height vs terminal rows
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
const currentQ = sessionRequest.questions[safeIndex];
|
|
241
|
+
const optionCount = currentQ?.options?.length ?? 0;
|
|
242
|
+
const estimatedContentHeight = 2 + 3 + 3 + optionCount * 2 + 2 + 6 + 2;
|
|
243
|
+
const nextOverflow = estimatedContentHeight > terminalRows;
|
|
244
|
+
setIsOverflowing((prev) => (prev === nextOverflow ? prev : nextOverflow));
|
|
245
|
+
}, [safeIndex, sessionRequest.questions, terminalRows]);
|
|
246
|
+
// Handle answer confirmation
|
|
247
|
+
const handleConfirm = async (userAnswers) => {
|
|
248
|
+
setSubmitting(true);
|
|
249
|
+
try {
|
|
250
|
+
const sessionManager = new SessionManager({
|
|
251
|
+
baseDir: getSessionDirectory(),
|
|
252
|
+
});
|
|
253
|
+
// Add elaborate requests for marked questions
|
|
254
|
+
const allAnswers = [...userAnswers];
|
|
255
|
+
elaborateMarks.forEach((customExplanation, questionIndex) => {
|
|
256
|
+
const question = sessionRequest.questions[questionIndex];
|
|
257
|
+
if (question) {
|
|
258
|
+
const elaborateRequest = ResponseFormatter.formatElaborateRequest(questionIndex, question.title, question.prompt, customExplanation || undefined);
|
|
259
|
+
allAnswers.push({
|
|
260
|
+
questionIndex,
|
|
261
|
+
customText: elaborateRequest,
|
|
262
|
+
timestamp: new Date().toISOString(),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
await sessionManager.saveSessionAnswers(sessionId, {
|
|
267
|
+
answers: allAnswers,
|
|
268
|
+
sessionId,
|
|
269
|
+
timestamp: new Date().toISOString(),
|
|
270
|
+
callId: sessionRequest.callId,
|
|
271
|
+
});
|
|
272
|
+
onComplete?.(false);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
console.error("Failed to save answers:", error);
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
if (isMountedRef.current) {
|
|
279
|
+
setSubmitting(false);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
// Handle going back from review
|
|
284
|
+
const handleGoBack = () => {
|
|
285
|
+
setShowReview(false);
|
|
286
|
+
};
|
|
287
|
+
// Handle advance to next question or review
|
|
288
|
+
const handleAdvanceToNext = () => {
|
|
289
|
+
if (currentQuestionIndex < sessionRequest.questions.length - 1) {
|
|
290
|
+
setCurrentQuestionIndex((prev) => prev + 1);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
setShowReview(true);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
// Handle session rejection
|
|
297
|
+
const handleRejectSession = async (reason) => {
|
|
298
|
+
try {
|
|
299
|
+
const sessionManager = new SessionManager({
|
|
300
|
+
baseDir: getSessionDirectory(),
|
|
301
|
+
});
|
|
302
|
+
await sessionManager.rejectSession(sessionId, reason);
|
|
303
|
+
if (onComplete) {
|
|
304
|
+
onComplete(true, reason);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
console.error("Failed to reject session:", error);
|
|
309
|
+
setShowRejectionConfirm(false);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
// Handle elaborate option selection
|
|
313
|
+
const handleElaborateSelect = () => {
|
|
314
|
+
const isMarking = !elaborateMarks.has(currentQuestionIndex);
|
|
315
|
+
setElaborateMarks((prev) => {
|
|
316
|
+
const newMarks = new Map(prev);
|
|
317
|
+
if (newMarks.has(currentQuestionIndex)) {
|
|
318
|
+
newMarks.delete(currentQuestionIndex);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
const existingText = prev.get(currentQuestionIndex) || "";
|
|
322
|
+
newMarks.set(currentQuestionIndex, existingText);
|
|
323
|
+
}
|
|
324
|
+
return newMarks;
|
|
325
|
+
});
|
|
326
|
+
// In single-select mode, clear selected option when marking elaborate
|
|
327
|
+
if (isMarking && !currentQuestion.multiSelect) {
|
|
328
|
+
setAnswers((prev) => {
|
|
329
|
+
const existing = prev.get(currentQuestionIndex);
|
|
330
|
+
if (existing?.selectedOption) {
|
|
331
|
+
const newAnswers = new Map(prev);
|
|
332
|
+
newAnswers.set(currentQuestionIndex, {
|
|
333
|
+
...existing,
|
|
334
|
+
selectedOption: undefined,
|
|
335
|
+
});
|
|
336
|
+
return newAnswers;
|
|
337
|
+
}
|
|
338
|
+
return prev;
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
// Handle elaborate text change
|
|
343
|
+
const handleElaborateTextChange = (text) => {
|
|
344
|
+
setElaborateMarks((prev) => {
|
|
345
|
+
const newMarks = new Map(prev);
|
|
346
|
+
newMarks.set(currentQuestionIndex, text);
|
|
347
|
+
return newMarks;
|
|
348
|
+
});
|
|
349
|
+
};
|
|
350
|
+
// Keyboard handling for abandoned confirmation dialog
|
|
351
|
+
useKeyboard((key) => {
|
|
352
|
+
if (!showAbandonedConfirm)
|
|
353
|
+
return;
|
|
354
|
+
if (key.name === "up") {
|
|
355
|
+
setAbandonedFocusedIndex((prev) => Math.max(0, prev - 1));
|
|
356
|
+
}
|
|
357
|
+
if (key.name === "down") {
|
|
358
|
+
setAbandonedFocusedIndex((prev) => Math.min(1, prev + 1));
|
|
359
|
+
}
|
|
360
|
+
if (key.name === "return") {
|
|
361
|
+
if (abandonedFocusedIndex === 0) {
|
|
362
|
+
setAbandonedConfirmed(true);
|
|
363
|
+
setShowAbandonedConfirm(false);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
onAbandonedCancel?.();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (key.name === "escape") {
|
|
370
|
+
onAbandonedCancel?.();
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
// Global keyboard shortcuts and navigation
|
|
374
|
+
useKeyboard((key) => {
|
|
375
|
+
// Don't handle navigation when showing review, submitting, or confirming
|
|
376
|
+
if (showReview || submitting || showRejectionConfirm || showAbandonedConfirm)
|
|
377
|
+
return;
|
|
378
|
+
// Derive text-input state from both focusContext and focusedOptionIndex
|
|
379
|
+
const isInTextInput = focusContext !== "option" ||
|
|
380
|
+
focusedOptionIndex >= currentQuestion.options.length;
|
|
381
|
+
// Esc key - show rejection confirmation
|
|
382
|
+
if (key.name === "escape") {
|
|
383
|
+
setShowRejectionConfirm(true);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
// Ctrl+R: Quick submit with recommended options
|
|
387
|
+
if (key.name?.toLowerCase() === KEYS.QUICK_SUBMIT &&
|
|
388
|
+
key.ctrl &&
|
|
389
|
+
hasAnyRecommendedInSession &&
|
|
390
|
+
!isInTextInput) {
|
|
391
|
+
const newAnswers = new Map(answers);
|
|
392
|
+
for (let i = 0; i < sessionRequest.questions.length; i++) {
|
|
393
|
+
const question = sessionRequest.questions[i];
|
|
394
|
+
const existingAnswer = newAnswers.get(i);
|
|
395
|
+
// Skip if already answered
|
|
396
|
+
if (existingAnswer?.selectedOption ||
|
|
397
|
+
existingAnswer?.selectedOptions?.length ||
|
|
398
|
+
existingAnswer?.customText) {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const recommendedOptions = question.options.filter((opt) => isRecommendedOption(opt.label));
|
|
402
|
+
if (recommendedOptions.length > 0) {
|
|
403
|
+
if (question.multiSelect) {
|
|
404
|
+
newAnswers.set(i, {
|
|
405
|
+
selectedOptions: recommendedOptions.map((opt) => opt.label),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
newAnswers.set(i, {
|
|
410
|
+
selectedOption: recommendedOptions[0].label,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
setAnswers(newAnswers);
|
|
416
|
+
setShowReview(true);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
// R key: Select recommended options for current question
|
|
420
|
+
if (key.name?.toLowerCase() === KEYS.RECOMMEND &&
|
|
421
|
+
!key.ctrl &&
|
|
422
|
+
!isInTextInput &&
|
|
423
|
+
hasRecommendedOptions) {
|
|
424
|
+
const question = currentQuestion;
|
|
425
|
+
const recommendedOptions = question.options.filter((opt) => isRecommendedOption(opt.label));
|
|
426
|
+
if (recommendedOptions.length > 0) {
|
|
427
|
+
if (question.multiSelect) {
|
|
428
|
+
setAnswers((prev) => {
|
|
429
|
+
const newAnswers = new Map(prev);
|
|
430
|
+
newAnswers.set(currentQuestionIndex, {
|
|
431
|
+
...newAnswers.get(currentQuestionIndex),
|
|
432
|
+
selectedOptions: recommendedOptions.map((opt) => opt.label),
|
|
433
|
+
});
|
|
434
|
+
return newAnswers;
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
handleSelectOption(recommendedOptions[0].label);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
// Tab/Shift+Tab: Global question navigation
|
|
444
|
+
if (key.name === "tab" && !isInTextInput) {
|
|
445
|
+
if (key.shift) {
|
|
446
|
+
setCurrentQuestionIndex((prev) => Math.max(0, prev - 1));
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
setCurrentQuestionIndex((prev) => Math.min(sessionRequest.questions.length - 1, prev + 1));
|
|
450
|
+
}
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
// Left/Right arrow: question navigation (only when NOT in text input)
|
|
454
|
+
if (!isInTextInput && key.name === "left" && currentQuestionIndex > 0) {
|
|
455
|
+
setCurrentQuestionIndex((prev) => prev - 1);
|
|
456
|
+
}
|
|
457
|
+
if (!isInTextInput &&
|
|
458
|
+
key.name === "right" &&
|
|
459
|
+
currentQuestionIndex < sessionRequest.questions.length - 1) {
|
|
460
|
+
setCurrentQuestionIndex((prev) => prev + 1);
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
const currentAnswer = answers.get(currentQuestionIndex);
|
|
464
|
+
// Show abandoned session confirmation
|
|
465
|
+
if (showAbandonedConfirm) {
|
|
466
|
+
const abandonedOptions = [
|
|
467
|
+
{ label: t("abandoned.continue") },
|
|
468
|
+
{ label: t("abandoned.cancel") },
|
|
469
|
+
];
|
|
470
|
+
return (_jsx("box", { style: { flexDirection: "column", paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1 }, children: _jsxs("box", { style: {
|
|
471
|
+
borderStyle: "rounded",
|
|
472
|
+
borderColor: theme.borders.warning,
|
|
473
|
+
flexDirection: "column",
|
|
474
|
+
padding: 1,
|
|
475
|
+
}, children: [_jsx("box", { style: { marginBottom: 1 }, children: _jsx("text", { style: { fg: theme.colors.warning, bold: true }, children: t("abandoned.title") }) }), _jsx("box", { style: { marginBottom: 1 }, children: _jsx("text", { children: t("abandoned.message") }) }), abandonedOptions.map((option, index) => {
|
|
476
|
+
const isFocused = index === abandonedFocusedIndex;
|
|
477
|
+
const rowBg = isFocused
|
|
478
|
+
? theme.components.options.focusedBg
|
|
479
|
+
: undefined;
|
|
480
|
+
return (_jsx("box", { style: { marginTop: index > 0 ? 1 : 0 }, children: _jsx("text", { bg: rowBg, fg: isFocused ? theme.colors.focused : theme.colors.text, style: { bold: isFocused }, children: `${isFocused ? "> " : " "}${option.label}` }) }, index));
|
|
481
|
+
}), _jsx("box", { style: { marginTop: 1 }, children: _jsx("text", { fg: theme.colors.textDim, children: "\u2191\u2193 Navigate | Enter Select" }) })] }) }));
|
|
482
|
+
}
|
|
483
|
+
// Show rejection confirmation
|
|
484
|
+
if (showRejectionConfirm) {
|
|
485
|
+
return (_jsx("box", { style: { flexDirection: "column", paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1 }, children: _jsx(ConfirmationDialog, { message: t("confirmation.rejectMessage"), onCancel: () => setShowRejectionConfirm(false), onQuit: () => process.exit(0), onReject: handleRejectSession }) }));
|
|
486
|
+
}
|
|
487
|
+
// Show review screen
|
|
488
|
+
if (showReview) {
|
|
489
|
+
return (_jsx("box", { style: { flexDirection: "column", paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1 }, children: _jsx(ReviewScreen, { isSubmitting: submitting, answers: answers, elapsedLabel: elapsedLabel, onConfirm: handleConfirm, onGoBack: handleGoBack, questions: sessionRequest.questions, sessionId: sessionId, elaborateMarks: elaborateMarks }) }));
|
|
490
|
+
}
|
|
491
|
+
// Show question display (default)
|
|
492
|
+
return (_jsx("box", { style: { flexDirection: "column", paddingLeft: 2, paddingRight: 2 }, children: _jsx(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, onSelectIndex: (idx) => setCurrentQuestionIndex(Math.max(0, Math.min(idx, sessionRequest.questions.length - 1))), showSessionSwitching: hasMultipleSessions && !showReview && !showRejectionConfirm }) }));
|
|
493
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
2
|
+
import { useTheme } from "../ThemeProvider.js";
|
|
3
|
+
import { useTerminalDimensions } from "../hooks/useTerminalDimensions.js";
|
|
4
|
+
/* ------------------------------------------------------------------ */
|
|
5
|
+
/* Helpers */
|
|
6
|
+
/* ------------------------------------------------------------------ */
|
|
7
|
+
/** Simple visual-width truncation with ellipsis. */
|
|
8
|
+
function truncate(value, maxChars) {
|
|
9
|
+
if (maxChars <= 0)
|
|
10
|
+
return "";
|
|
11
|
+
if (value.length <= maxChars)
|
|
12
|
+
return value;
|
|
13
|
+
return value.slice(0, maxChars - 1) + "…";
|
|
14
|
+
}
|
|
15
|
+
const isAnswerPresent = (answer) => {
|
|
16
|
+
if (!answer)
|
|
17
|
+
return false;
|
|
18
|
+
if (answer.selectedOption)
|
|
19
|
+
return true;
|
|
20
|
+
if (answer.customText)
|
|
21
|
+
return true;
|
|
22
|
+
if (answer.selectedOptions && answer.selectedOptions.length > 0)
|
|
23
|
+
return true;
|
|
24
|
+
return false;
|
|
25
|
+
};
|
|
26
|
+
/* ------------------------------------------------------------------ */
|
|
27
|
+
/* Component */
|
|
28
|
+
/* ------------------------------------------------------------------ */
|
|
29
|
+
/**
|
|
30
|
+
* TabBar — minimal question tab strip with progress indicators.
|
|
31
|
+
*
|
|
32
|
+
* Active tab is clearly indicated; answered state is shown subtly via color.
|
|
33
|
+
* Status symbols: ✓ answered, ⟲ elaborate, · unanswered
|
|
34
|
+
*/
|
|
35
|
+
export const TabBar = ({ currentIndex, questions, answers, tabLabel, elaborateMarks, onSelectIndex, }) => {
|
|
36
|
+
const { theme } = useTheme();
|
|
37
|
+
const { width: columns } = useTerminalDimensions();
|
|
38
|
+
// A little breathing room at both edges.
|
|
39
|
+
const paddingX = 2;
|
|
40
|
+
const innerWidth = Math.max(0, columns - paddingX * 2);
|
|
41
|
+
const answeredCount = questions.reduce((count, _q, index) => {
|
|
42
|
+
const answer = answers.get(index);
|
|
43
|
+
return count + (isAnswerPresent(answer) ? 1 : 0);
|
|
44
|
+
}, 0);
|
|
45
|
+
const progressText = `${answeredCount}/${questions.length}`;
|
|
46
|
+
const separator = " │ ";
|
|
47
|
+
const separatorLen = separator.length;
|
|
48
|
+
// Build labels and keep the entire bar to a single line.
|
|
49
|
+
const labelCount = Math.max(questions.length, 1);
|
|
50
|
+
const maxLeftWidth = Math.max(0, innerWidth - progressText.length - 1);
|
|
51
|
+
const availableForLabels = Math.max(0, maxLeftWidth - separatorLen * (labelCount - 1));
|
|
52
|
+
const maxLabelChars = Math.max(4, Math.floor(availableForLabels / labelCount));
|
|
53
|
+
const tabs = questions.map((question, index) => {
|
|
54
|
+
const isActive = index === currentIndex;
|
|
55
|
+
const answer = answers.get(index);
|
|
56
|
+
const isAnswered = isAnswerPresent(answer);
|
|
57
|
+
const isElaborate = elaborateMarks?.has(index) ?? false;
|
|
58
|
+
const baseLabel = tabLabel || question.title || `Question ${index + 1}`;
|
|
59
|
+
const label = truncate(baseLabel, maxLabelChars - 2);
|
|
60
|
+
const status = isElaborate ? "⟲" : isAnswered ? "✓" : "·";
|
|
61
|
+
return { index, isActive, isAnswered, isElaborate, label, status };
|
|
62
|
+
});
|
|
63
|
+
return (_jsxs("box", { style: { flexDirection: "row", width: columns, paddingLeft: paddingX, paddingRight: paddingX, justifyContent: "space-between" }, children: [_jsx("box", { style: { flexDirection: "row" }, children: tabs.map((tab, idx) => (_jsxs("box", { style: { flexDirection: "row", backgroundColor: tab.isActive ? theme.colors.surfaceAlt : undefined, paddingLeft: tab.isActive ? 1 : 0, paddingRight: tab.isActive ? 1 : 0 }, onMouseDown: () => onSelectIndex?.(tab.index), children: [_jsx("text", { style: {
|
|
64
|
+
fg: tab.isElaborate
|
|
65
|
+
? theme.colors.warning
|
|
66
|
+
: tab.isAnswered
|
|
67
|
+
? theme.components.tabBar.answered
|
|
68
|
+
: theme.colors.unansweredHighlight,
|
|
69
|
+
}, children: tab.status }), _jsx("text", { children: " " }), _jsx("text", { style: {
|
|
70
|
+
bold: tab.isActive,
|
|
71
|
+
underline: tab.isActive,
|
|
72
|
+
fg: tab.isActive
|
|
73
|
+
? theme.colors.primary
|
|
74
|
+
: tab.isAnswered
|
|
75
|
+
? theme.components.tabBar.answered
|
|
76
|
+
: theme.components.tabBar.unanswered,
|
|
77
|
+
dim: !tab.isActive && !tab.isAnswered,
|
|
78
|
+
}, children: tab.label }), idx < tabs.length - 1 ? (_jsx("text", { style: { fg: theme.components.tabBar.divider }, children: separator })) : null] }, tab.index))) }), _jsx("text", { style: { fg: theme.colors.textDim, dim: true }, children: progressText })] }));
|
|
79
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { t } from "../../i18n/index.js";
|
|
4
|
+
import { useTheme } from "../ThemeProvider.js";
|
|
5
|
+
/**
|
|
6
|
+
* ThemeIndicator — shows a temporary centered notification when theme changes.
|
|
7
|
+
* Displays for 1.5 seconds then hides.
|
|
8
|
+
*/
|
|
9
|
+
export const ThemeIndicator = () => {
|
|
10
|
+
const { theme, themeName } = useTheme();
|
|
11
|
+
const [visible, setVisible] = useState(false);
|
|
12
|
+
const prevThemeRef = useRef(themeName);
|
|
13
|
+
const isFirstRender = useRef(true);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
// Skip showing indicator on first render
|
|
16
|
+
if (isFirstRender.current) {
|
|
17
|
+
isFirstRender.current = false;
|
|
18
|
+
prevThemeRef.current = themeName;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
// Only show when theme actually changes
|
|
22
|
+
if (themeName !== prevThemeRef.current) {
|
|
23
|
+
prevThemeRef.current = themeName;
|
|
24
|
+
setVisible(true);
|
|
25
|
+
const timer = setTimeout(() => {
|
|
26
|
+
setVisible(false);
|
|
27
|
+
}, 1500);
|
|
28
|
+
return () => clearTimeout(timer);
|
|
29
|
+
}
|
|
30
|
+
}, [themeName]);
|
|
31
|
+
if (!visible) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return (_jsx("box", { style: { justifyContent: "center", marginTop: 0, marginBottom: 0 }, children: _jsxs("box", { style: { flexDirection: "row" }, children: [_jsx("text", { style: { fg: theme.colors.textDim }, children: `${t("ui.themeLabel")} ` }), _jsx("text", { style: { fg: theme.colors.primary }, children: themeName })] }) }));
|
|
35
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { TextAttributes } from "@opentui/core";
|
|
4
|
+
import { useTheme } from "../ThemeProvider.js";
|
|
5
|
+
/**
|
|
6
|
+
* Toast component for brief non-blocking notifications.
|
|
7
|
+
* Auto-dismisses after specified duration (default 3000ms).
|
|
8
|
+
* Uses OpenTUI Timeline for smooth lifecycle management.
|
|
9
|
+
*/
|
|
10
|
+
export const Toast = ({ message, type = "success", onDismiss, duration = 3000, title, variant = "default", }) => {
|
|
11
|
+
const { theme } = useTheme();
|
|
12
|
+
const [visible, setVisible] = useState(true);
|
|
13
|
+
// Auto-dismiss after duration
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const timer = setTimeout(() => {
|
|
16
|
+
setVisible(false);
|
|
17
|
+
onDismiss();
|
|
18
|
+
}, duration);
|
|
19
|
+
return () => clearTimeout(timer);
|
|
20
|
+
}, [duration, onDismiss]);
|
|
21
|
+
if (!visible)
|
|
22
|
+
return null;
|
|
23
|
+
// Color based on type
|
|
24
|
+
const colorMap = {
|
|
25
|
+
success: theme.components.toast.success,
|
|
26
|
+
error: theme.components.toast.error,
|
|
27
|
+
info: theme.components.toast.info,
|
|
28
|
+
warning: theme.colors.warning,
|
|
29
|
+
};
|
|
30
|
+
const color = colorMap[type] ?? theme.components.toast.info;
|
|
31
|
+
const isPill = variant === "pill";
|
|
32
|
+
const backgroundColor = isPill && type === "success"
|
|
33
|
+
? theme.components.toast.successPillBg
|
|
34
|
+
: undefined;
|
|
35
|
+
return (_jsxs("box", { style: {
|
|
36
|
+
borderColor: theme.components.toast.border,
|
|
37
|
+
borderStyle: isPill ? undefined : "rounded",
|
|
38
|
+
backgroundColor: backgroundColor,
|
|
39
|
+
justifyContent: isPill ? "center" : undefined,
|
|
40
|
+
paddingX: 2,
|
|
41
|
+
paddingY: 0,
|
|
42
|
+
flexDirection: "column",
|
|
43
|
+
}, children: [title && (_jsx("text", { style: { fg: color, attributes: TextAttributes.BOLD }, children: title })), message && (_jsx("text", { style: { fg: theme.colors.text }, children: message }))] }));
|
|
44
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx } from "@opentui/react/jsx-runtime";
|
|
2
|
+
import { useTheme } from "../ThemeProvider.js";
|
|
3
|
+
/**
|
|
4
|
+
* UpdateBadge — compact header indicator when a new version is available.
|
|
5
|
+
*
|
|
6
|
+
* Color-coding by severity:
|
|
7
|
+
* patch → success (green) — minor fix
|
|
8
|
+
* minor → warning (yellow) — new features
|
|
9
|
+
* major → error (red) — possible breaking changes
|
|
10
|
+
*/
|
|
11
|
+
export const UpdateBadge = ({ updateType, latestVersion, }) => {
|
|
12
|
+
const { theme } = useTheme();
|
|
13
|
+
const colorMap = {
|
|
14
|
+
patch: theme.colors.success,
|
|
15
|
+
minor: theme.colors.warning,
|
|
16
|
+
major: theme.colors.error,
|
|
17
|
+
};
|
|
18
|
+
const color = colorMap[updateType] ?? theme.colors.info;
|
|
19
|
+
// Patch updates are concise; minor/major show the target version
|
|
20
|
+
const label = updateType === "patch"
|
|
21
|
+
? "↑ Update"
|
|
22
|
+
: `↑ v${latestVersion}`;
|
|
23
|
+
return (_jsx("box", { style: { marginLeft: 1 }, children: _jsx("text", { style: { bg: color, fg: "#000000", bold: true }, children: ` ${label} ` }) }));
|
|
24
|
+
};
|