auq-mcp-server 2.7.0 → 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.
Files changed (36) hide show
  1. package/dist/package.json +3 -3
  2. package/dist/src/config/__tests__/ConfigLoader.test.js +7 -7
  3. package/dist/src/config/defaults.js +1 -1
  4. package/dist/src/config/types.js +1 -1
  5. package/dist/src/i18n/types.js +0 -1
  6. package/dist/src/tui/shared/session-events.js +0 -1
  7. package/dist/src/tui/shared/themes/types.js +0 -1
  8. package/dist/src/tui/shared/types.js +0 -1
  9. package/dist/src/tui-opentui/ConfigContext.js +10 -0
  10. package/dist/src/tui-opentui/ThemeProvider.js +73 -0
  11. package/dist/src/tui-opentui/app.js +536 -0
  12. package/dist/src/tui-opentui/components/AnimatedGradient.js +56 -0
  13. package/dist/src/tui-opentui/components/ConfirmationDialog.js +89 -0
  14. package/dist/src/tui-opentui/components/CustomInput.js +25 -0
  15. package/dist/src/tui-opentui/components/ErrorBoundary.js +26 -0
  16. package/dist/src/tui-opentui/components/Footer.js +92 -0
  17. package/dist/src/tui-opentui/components/Header.js +46 -0
  18. package/dist/src/tui-opentui/components/MarkdownPrompt.js +13 -0
  19. package/dist/src/tui-opentui/components/OptionsList.js +258 -0
  20. package/dist/src/tui-opentui/components/QuestionDisplay.js +23 -0
  21. package/dist/src/tui-opentui/components/ReviewScreen.js +81 -0
  22. package/dist/src/tui-opentui/components/SessionDots.js +86 -0
  23. package/dist/src/tui-opentui/components/SessionPicker.js +162 -0
  24. package/dist/src/tui-opentui/components/SingleLineTextInput.js +9 -0
  25. package/dist/src/tui-opentui/components/StepperView.js +493 -0
  26. package/dist/src/tui-opentui/components/TabBar.js +79 -0
  27. package/dist/src/tui-opentui/components/ThemeIndicator.js +35 -0
  28. package/dist/src/tui-opentui/components/Toast.js +44 -0
  29. package/dist/src/tui-opentui/components/UpdateBadge.js +24 -0
  30. package/dist/src/tui-opentui/components/UpdateOverlay.js +162 -0
  31. package/dist/src/tui-opentui/components/WaitingScreen.js +44 -0
  32. package/dist/src/tui-opentui/hooks/useSessionWatcher.js +69 -0
  33. package/dist/src/tui-opentui/hooks/useTerminalDimensions.js +8 -0
  34. package/dist/src/tui-opentui/utils/syntaxStyle.js +64 -0
  35. package/dist/src/update/types.js +0 -1
  36. 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
+ };