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.
Files changed (35) hide show
  1. package/dist/bin/tui-app.js +199 -18
  2. package/dist/package.json +1 -1
  3. package/dist/src/i18n/locales/en.js +3 -0
  4. package/dist/src/i18n/locales/ko.js +3 -0
  5. package/dist/src/tui/components/Footer.js +6 -1
  6. package/dist/src/tui/components/OptionsList.js +81 -9
  7. package/dist/src/tui/components/QuestionDisplay.js +5 -9
  8. package/dist/src/tui/components/SessionDots.js +65 -0
  9. package/dist/src/tui/components/SessionPicker.js +159 -0
  10. package/dist/src/tui/components/StepperView.js +74 -9
  11. package/dist/src/tui/components/__tests__/SessionDots.test.js +92 -0
  12. package/dist/src/tui/components/__tests__/SessionPicker.test.js +125 -0
  13. package/dist/src/tui/components/__tests__/StepperView.state.test.js +101 -0
  14. package/dist/src/tui/themes/catppuccin-latte.js +18 -0
  15. package/dist/src/tui/themes/catppuccin-mocha.js +18 -0
  16. package/dist/src/tui/themes/dark.js +18 -0
  17. package/dist/src/tui/themes/dracula.js +18 -0
  18. package/dist/src/tui/themes/github-dark.js +18 -0
  19. package/dist/src/tui/themes/github-light.js +18 -0
  20. package/dist/src/tui/themes/gruvbox-dark.js +18 -0
  21. package/dist/src/tui/themes/gruvbox-light.js +18 -0
  22. package/dist/src/tui/themes/light.js +18 -0
  23. package/dist/src/tui/themes/monokai.js +18 -0
  24. package/dist/src/tui/themes/nord.js +18 -0
  25. package/dist/src/tui/themes/one-dark.js +18 -0
  26. package/dist/src/tui/themes/rose-pine.js +18 -0
  27. package/dist/src/tui/themes/solarized-dark.js +18 -0
  28. package/dist/src/tui/themes/solarized-light.js +18 -0
  29. package/dist/src/tui/themes/tokyo-night.js +18 -0
  30. package/dist/src/tui/types.js +1 -0
  31. package/dist/src/tui/utils/__tests__/relativeTime.test.js +31 -0
  32. package/dist/src/tui/utils/__tests__/sessionSwitching.test.js +82 -0
  33. package/dist/src/tui/utils/relativeTime.js +24 -0
  34. package/dist/src/tui/utils/sessionSwitching.js +56 -0
  35. package/package.json +1 -1
@@ -1,7 +1,10 @@
1
- import { Box, render, Text } from "ink";
2
- import React, { useEffect, useMemo, useRef, useState } from "react";
1
+ import { Box, render, Text, useInput } from "ink";
2
+ import { promises as fs } from "fs";
3
+ import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react";
3
4
  import { ensureDirectoryExists, getSessionDirectory, } from "../src/session/utils.js";
4
5
  import { Header } from "../src/tui/components/Header.js";
6
+ import { SessionDots } from "../src/tui/components/SessionDots.js";
7
+ import { SessionPicker } from "../src/tui/components/SessionPicker.js";
5
8
  import { StepperView } from "../src/tui/components/StepperView.js";
6
9
  import { ThemeIndicator } from "../src/tui/components/ThemeIndicator.js";
7
10
  import { Toast } from "../src/tui/components/Toast.js";
@@ -10,12 +13,17 @@ import { createNotificationBatcher, showProgress, clearProgress, calculateProgre
10
13
  import { createTUIWatcher } from "../src/tui/session-watcher.js";
11
14
  import { ThemeProvider } from "../src/tui/ThemeProvider.js";
12
15
  import { ConfigProvider } from "../src/tui/ConfigContext.js";
16
+ import { getAdjustedIndexAfterRemoval, getDirectJumpIndex, getNextSessionIndex, getPrevSessionIndex, } from "../src/tui/utils/sessionSwitching.js";
13
17
  const App = ({ config }) => {
14
18
  const [state, setState] = useState({ mode: "WAITING" });
15
19
  const [sessionQueue, setSessionQueue] = useState([]);
20
+ const [activeSessionIndex, setActiveSessionIndex] = useState(0);
21
+ const [sessionUIStates, setSessionUIStates] = useState({});
16
22
  const [isInitialized, setIsInitialized] = useState(false);
17
23
  const [toast, setToast] = useState(null);
18
24
  const [showSessionLog, setShowSessionLog] = useState(true);
25
+ const [showSessionPicker, setShowSessionPicker] = useState(false);
26
+ const [isInReviewOrRejection, setIsInReviewOrRejection] = useState(false);
19
27
  // Get session directory for logging
20
28
  const sessionDir = getSessionDirectory();
21
29
  // Notification configuration from config
@@ -109,20 +117,172 @@ const App = ({ config }) => {
109
117
  if (!isInitialized)
110
118
  return;
111
119
  if (state.mode === "WAITING" && sessionQueue.length > 0) {
112
- const [nextSession, ...rest] = sessionQueue;
113
- setSessionQueue(rest);
114
- setState({ mode: "PROCESSING", session: nextSession });
120
+ setState({ mode: "PROCESSING" });
121
+ setActiveSessionIndex(0);
122
+ return;
123
+ }
124
+ if (state.mode === "PROCESSING" && sessionQueue.length === 0) {
125
+ setState({ mode: "WAITING" });
126
+ setActiveSessionIndex(0);
115
127
  }
116
- }, [state, sessionQueue, isInitialized]);
128
+ }, [state.mode, sessionQueue.length, isInitialized]);
117
129
  // Show toast notification
118
130
  const showToast = (message, type = "success", title) => {
119
131
  setToast({ message, type, title });
120
132
  };
133
+ useEffect(() => {
134
+ if (state.mode !== "PROCESSING" || sessionQueue.length <= 1) {
135
+ return;
136
+ }
137
+ let isCancelled = false;
138
+ let isChecking = false;
139
+ const checkPausedSessionStatuses = async () => {
140
+ if (isCancelled || isChecking) {
141
+ return;
142
+ }
143
+ isChecking = true;
144
+ try {
145
+ const checks = await Promise.all(sessionQueue.map(async (session, index) => {
146
+ if (index === activeSessionIndex) {
147
+ return null;
148
+ }
149
+ const statusPath = `${sessionDir}/${session.sessionId}/status.json`;
150
+ try {
151
+ const content = await fs.readFile(statusPath, "utf8");
152
+ const parsed = JSON.parse(content);
153
+ if (parsed.status === "timed_out" ||
154
+ parsed.status === "completed" ||
155
+ parsed.status === "rejected" ||
156
+ parsed.status === "abandoned") {
157
+ return {
158
+ notifyAsTimedOut: parsed.status === "timed_out",
159
+ session,
160
+ };
161
+ }
162
+ return null;
163
+ }
164
+ catch {
165
+ return {
166
+ notifyAsTimedOut: true,
167
+ session,
168
+ };
169
+ }
170
+ }));
171
+ if (isCancelled) {
172
+ return;
173
+ }
174
+ const sessionsToRemove = checks.filter((entry) => entry !== null);
175
+ if (sessionsToRemove.length === 0) {
176
+ return;
177
+ }
178
+ const timedOutSession = sessionsToRemove.find((entry) => entry.notifyAsTimedOut);
179
+ if (timedOutSession) {
180
+ const title = timedOutSession.session.sessionRequest.questions[0]?.title ||
181
+ timedOutSession.session.sessionId.slice(0, 8);
182
+ showToast(`Session '${title}' timed out`, "info");
183
+ }
184
+ const idsToRemove = new Set(sessionsToRemove.map((entry) => entry.session.sessionId));
185
+ setSessionUIStates((prev) => {
186
+ const next = { ...prev };
187
+ for (const sessionId of idsToRemove) {
188
+ delete next[sessionId];
189
+ }
190
+ return next;
191
+ });
192
+ setSessionQueue((prev) => {
193
+ let nextQueue = [...prev];
194
+ let nextActiveIndex = activeSessionIndex;
195
+ const removalIndices = Array.from(idsToRemove)
196
+ .map((sessionId) => nextQueue.findIndex((session) => session.sessionId === sessionId))
197
+ .filter((idx) => idx !== -1)
198
+ .sort((a, b) => b - a);
199
+ for (const removalIndex of removalIndices) {
200
+ nextQueue = nextQueue.filter((_, idx) => idx !== removalIndex);
201
+ nextActiveIndex = getAdjustedIndexAfterRemoval(removalIndex, nextActiveIndex, nextQueue.length);
202
+ }
203
+ setActiveSessionIndex(nextActiveIndex);
204
+ setState(nextQueue.length === 0
205
+ ? { mode: "WAITING" }
206
+ : { mode: "PROCESSING" });
207
+ return nextQueue;
208
+ });
209
+ }
210
+ finally {
211
+ isChecking = false;
212
+ }
213
+ };
214
+ const interval = setInterval(() => {
215
+ void checkPausedSessionStatuses();
216
+ }, 2000);
217
+ return () => {
218
+ isCancelled = true;
219
+ clearInterval(interval);
220
+ };
221
+ }, [activeSessionIndex, sessionDir, sessionQueue, state.mode]);
121
222
  // Handle progress updates from StepperView
122
223
  const handleProgressUpdate = (answered, total) => {
123
224
  const percent = calculateProgress(answered, total);
124
225
  showProgress(percent, notificationConfig);
125
226
  };
227
+ const handleStateSnapshot = useCallback((sessionId, ui) => {
228
+ setSessionUIStates((prev) => ({
229
+ ...prev,
230
+ [sessionId]: ui,
231
+ }));
232
+ }, []);
233
+ const handleFlowStateChange = useCallback((flowState) => {
234
+ setIsInReviewOrRejection(flowState.showReview || flowState.showRejectionConfirm);
235
+ }, []);
236
+ const switchToSession = useCallback((targetIndex) => {
237
+ if (state.mode !== "PROCESSING" || sessionQueue.length <= 1) {
238
+ return;
239
+ }
240
+ const clampedIndex = Math.max(0, Math.min(targetIndex, sessionQueue.length - 1));
241
+ if (clampedIndex === activeSessionIndex) {
242
+ return;
243
+ }
244
+ const targetSession = sessionQueue[clampedIndex];
245
+ if (!targetSession) {
246
+ return;
247
+ }
248
+ setActiveSessionIndex(clampedIndex);
249
+ setShowSessionPicker(false);
250
+ }, [activeSessionIndex, sessionQueue, state.mode]);
251
+ const activeSession = state.mode === "PROCESSING" ? sessionQueue[activeSessionIndex] : undefined;
252
+ const canUseDirectJump = !activeSession ||
253
+ sessionUIStates[activeSession.sessionId]?.focusContext === "option" ||
254
+ sessionUIStates[activeSession.sessionId] === undefined;
255
+ useInput((input, key) => {
256
+ if (key.ctrl && input === "s") {
257
+ setShowSessionPicker(true);
258
+ return;
259
+ }
260
+ if (key.ctrl && input === "]") {
261
+ const nextIndex = getNextSessionIndex(activeSessionIndex, sessionQueue.length);
262
+ switchToSession(nextIndex);
263
+ return;
264
+ }
265
+ if (key.ctrl && input === "[") {
266
+ const prevIndex = getPrevSessionIndex(activeSessionIndex, sessionQueue.length);
267
+ switchToSession(prevIndex);
268
+ return;
269
+ }
270
+ if (!key.ctrl && !key.meta && /^[1-9]$/.test(input)) {
271
+ if (!canUseDirectJump) {
272
+ return;
273
+ }
274
+ const keyNumber = Number(input);
275
+ const targetIndex = getDirectJumpIndex(keyNumber, activeSessionIndex, sessionQueue.length);
276
+ if (targetIndex !== null) {
277
+ switchToSession(targetIndex);
278
+ }
279
+ }
280
+ }, {
281
+ isActive: state.mode === "PROCESSING" &&
282
+ !isInReviewOrRejection &&
283
+ !showSessionPicker &&
284
+ sessionQueue.length >= 2,
285
+ });
126
286
  // Handle session completion
127
287
  const handleSessionComplete = (wasRejected = false, rejectionReason) => {
128
288
  // Clear progress bar on session completion
@@ -139,16 +299,25 @@ const App = ({ config }) => {
139
299
  else {
140
300
  showToast("✅ Answers submitted successfully!", "success");
141
301
  }
142
- if (sessionQueue.length > 0) {
143
- // Auto-load next session
144
- const [nextSession, ...rest] = sessionQueue;
145
- setSessionQueue(rest);
146
- setState({ mode: "PROCESSING", session: nextSession });
147
- }
148
- else {
149
- // Return to WAITING
150
- setState({ mode: "WAITING" });
302
+ const completedSession = sessionQueue[activeSessionIndex];
303
+ if (completedSession) {
304
+ setSessionUIStates((prev) => {
305
+ if (!(completedSession.sessionId in prev)) {
306
+ return prev;
307
+ }
308
+ const next = { ...prev };
309
+ delete next[completedSession.sessionId];
310
+ return next;
311
+ });
151
312
  }
313
+ setSessionQueue((prev) => {
314
+ const removedIndex = activeSessionIndex;
315
+ const nextQueue = prev.filter((_, i) => i !== removedIndex);
316
+ const nextActiveIndex = getAdjustedIndexAfterRemoval(removedIndex, activeSessionIndex, nextQueue.length);
317
+ setActiveSessionIndex(nextActiveIndex);
318
+ setState(nextQueue.length === 0 ? { mode: "WAITING" } : { mode: "PROCESSING" });
319
+ return nextQueue;
320
+ });
152
321
  };
153
322
  // Render based on state
154
323
  if (!isInitialized) {
@@ -160,8 +329,13 @@ const App = ({ config }) => {
160
329
  }
161
330
  else {
162
331
  // PROCESSING mode
163
- const { session } = state;
164
- mainContent = (React.createElement(StepperView, { key: session.sessionId, onComplete: handleSessionComplete, onProgress: handleProgressUpdate, sessionId: session.sessionId, sessionRequest: session.sessionRequest }));
332
+ const session = sessionQueue[activeSessionIndex];
333
+ if (!session) {
334
+ mainContent = React.createElement(WaitingScreen, { queueCount: sessionQueue.length });
335
+ }
336
+ else {
337
+ mainContent = (React.createElement(StepperView, { key: session.sessionId, onComplete: handleSessionComplete, onProgress: handleProgressUpdate, initialState: sessionUIStates[session.sessionId], onStateSnapshot: handleStateSnapshot, onFlowStateChange: handleFlowStateChange, hasMultipleSessions: sessionQueue.length >= 2, sessionId: session.sessionId, sessionRequest: session.sessionRequest }));
338
+ }
165
339
  }
166
340
  // Render with header, toast overlay, and main content
167
341
  // Use theme from config, falling back to "system" if not specified
@@ -169,14 +343,21 @@ const App = ({ config }) => {
169
343
  return (React.createElement(ConfigProvider, { config: config },
170
344
  React.createElement(ThemeProvider, { initialTheme: initialTheme },
171
345
  React.createElement(Box, { flexDirection: "column", paddingX: 1 },
172
- React.createElement(Header, { pendingCount: sessionQueue.length }),
346
+ React.createElement(Header, { pendingCount: state.mode === "PROCESSING"
347
+ ? Math.max(0, sessionQueue.length - 1)
348
+ : sessionQueue.length }),
173
349
  mainContent,
350
+ state.mode === "PROCESSING" && sessionQueue.length >= 2 && (React.createElement(SessionDots, { sessions: sessionQueue, activeIndex: activeSessionIndex, sessionUIStates: sessionUIStates })),
174
351
  toast && (React.createElement(Box, { marginTop: 1, justifyContent: "center" },
175
352
  React.createElement(Toast, { message: toast.message, onDismiss: () => setToast(null), type: toast.type, title: toast.title, duration: 5000 }))),
176
353
  showSessionLog && (React.createElement(Box, { marginTop: 1 },
177
354
  React.createElement(Text, { dimColor: true },
178
355
  "[AUQ] Session directory: ",
179
356
  sessionDir))),
357
+ state.mode === "PROCESSING" && (React.createElement(SessionPicker, { isOpen: showSessionPicker, sessions: sessionQueue, activeIndex: activeSessionIndex, sessionUIStates: sessionUIStates, onSelectIndex: (idx) => {
358
+ switchToSession(idx);
359
+ setShowSessionPicker(false);
360
+ }, onClose: () => setShowSessionPicker(false) })),
180
361
  React.createElement(ThemeIndicator, null)))));
181
362
  };
182
363
  export const runTui = (config) => {
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auq-mcp-server",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "main": "dist/index.js",
5
5
  "bin": {
6
6
  "auq": "dist/bin/auq.js"
@@ -2,6 +2,9 @@ export const en = {
2
2
  footer: {
3
3
  options: "Options",
4
4
  questions: "Questions",
5
+ sessions: "Sessions",
6
+ jump: "Jump",
7
+ list: "List",
5
8
  select: "Select",
6
9
  selectNext: "Select & Next",
7
10
  toggle: "Toggle",
@@ -2,6 +2,9 @@ export const ko = {
2
2
  footer: {
3
3
  options: "옵션",
4
4
  questions: "질문",
5
+ sessions: "세션",
6
+ jump: "이동",
7
+ list: "목록",
5
8
  select: "선택",
6
9
  selectNext: "선택 후 다음",
7
10
  toggle: "토글",
@@ -7,7 +7,7 @@ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧",
7
7
  * Footer component - displays context-aware keybindings
8
8
  * Shows different shortcuts based on current focus context and question type
9
9
  */
10
- export const Footer = ({ focusContext, multiSelect, isReviewScreen = false, customInputValue = "", hasRecommendedOptions = false, hasAnyRecommendedInSession = false, isSubmitting = false, }) => {
10
+ export const Footer = ({ focusContext, multiSelect, isReviewScreen = false, showSessionSwitching = false, customInputValue = "", hasRecommendedOptions = false, hasAnyRecommendedInSession = false, isSubmitting = false, }) => {
11
11
  const { theme } = useTheme();
12
12
  const [spinnerFrame, setSpinnerFrame] = useState(0);
13
13
  // Animate spinner when submitting
@@ -68,6 +68,11 @@ export const Footer = ({ focusContext, multiSelect, isReviewScreen = false, cust
68
68
  if (hasAnyRecommendedInSession) {
69
69
  bindings.push({ key: "Ctrl+R", action: t("footer.quickSubmit") });
70
70
  }
71
+ if (showSessionSwitching) {
72
+ bindings.push({ key: "Ctrl+]/[", action: t("footer.sessions") });
73
+ bindings.push({ key: "1-9", action: t("footer.jump") });
74
+ bindings.push({ key: "Ctrl+S", action: t("footer.list") });
75
+ }
71
76
  bindings.push({ key: "Ctrl+T", action: t("footer.theme") });
72
77
  bindings.push({ key: "Esc", action: t("footer.reject") });
73
78
  return bindings;
@@ -4,25 +4,62 @@ import { t } from "../../i18n/index.js";
4
4
  import { useConfig } from "../ConfigContext.js";
5
5
  import { useTheme } from "../ThemeContext.js";
6
6
  import { isRecommendedOption } from "../utils/recommended.js";
7
- import { fitToVisualWidth } from "../utils/visualWidth.js";
7
+ import { fitToVisualWidth, getVisualWidth, padToVisualWidth, } from "../utils/visualWidth.js";
8
8
  import { MultiLineTextInput } from "./MultiLineTextInput.js";
9
9
  // isRecommendedOption is imported from ../utils/recommended.js
10
10
  /**
11
11
  * OptionsList displays answer choices and handles arrow key navigation
12
12
  * Uses ↑↓ to navigate, Enter to select
13
13
  */
14
- export const OptionsList = ({ isFocused, onSelect, options, selectedOption, showCustomInput = false, customValue = "", onCustomChange, onAdvance, multiSelect = false, onToggle, selectedOptions = [], onFocusContextChange, onRecommendedDetected, questionKey, autoSelectRecommended: autoSelectRecommendedProp, isElaborateMarked = false, onElaborateSelect, elaborateText = "", onElaborateTextChange, }) => {
14
+ export const OptionsList = ({ isFocused, onSelect, options, selectedOption, showCustomInput = false, customValue = "", onCustomChange, onAdvance, multiSelect = false, onToggle, selectedOptions = [], focusedIndex: focusedIndexProp, onFocusedIndexChange, onFocusContextChange, onRecommendedDetected, questionKey, autoSelectRecommended: autoSelectRecommendedProp, isElaborateMarked = false, onElaborateSelect, elaborateText = "", onElaborateTextChange, }) => {
15
15
  const { theme } = useTheme();
16
16
  const config = useConfig();
17
17
  // Use prop if provided, otherwise use config value
18
18
  const autoSelectRecommended = autoSelectRecommendedProp ?? config.autoSelectRecommended;
19
- const [focusedIndex, setFocusedIndex] = useState(0);
19
+ const [internalFocusedIndex, setInternalFocusedIndex] = useState(0);
20
+ const focusedIndex = focusedIndexProp ?? internalFocusedIndex;
21
+ const setFocusedIndex = (nextIndex) => {
22
+ const resolvedIndex = typeof nextIndex === "function" ? nextIndex(focusedIndex) : nextIndex;
23
+ if (focusedIndexProp === undefined) {
24
+ setInternalFocusedIndex(resolvedIndex);
25
+ }
26
+ onFocusedIndexChange?.(resolvedIndex);
27
+ };
20
28
  const { stdout } = useStdout();
21
29
  const columns = stdout?.columns ?? 80;
22
30
  const rowWidth = Math.max(20, columns - 2);
23
31
  const fitRow = (text) => {
24
32
  return fitToVisualWidth(text, rowWidth);
25
33
  };
34
+ // Wrap text to multiple lines respecting visual width and explicit newlines
35
+ const wrapText = (text, width) => {
36
+ // First split on explicit newlines, then wrap each segment by visual width
37
+ const segments = text.split("\n");
38
+ const lines = [];
39
+ for (const segment of segments) {
40
+ if (getVisualWidth(segment) <= width) {
41
+ lines.push(segment);
42
+ continue;
43
+ }
44
+ let currentLine = "";
45
+ let currentWidth = 0;
46
+ for (const char of segment) {
47
+ const charWidth = getVisualWidth(char);
48
+ if (currentWidth + charWidth > width && currentLine.length > 0) {
49
+ lines.push(currentLine);
50
+ currentLine = char;
51
+ currentWidth = charWidth;
52
+ }
53
+ else {
54
+ currentLine += char;
55
+ currentWidth += charWidth;
56
+ }
57
+ }
58
+ if (currentLine)
59
+ lines.push(currentLine);
60
+ }
61
+ return lines;
62
+ };
26
63
  // Calculate max index: include custom input and elaborate options if enabled
27
64
  // Options: [0..n-1] = regular options, [n] = custom input, [n+1] = elaborate
28
65
  const customInputIndex = options.length;
@@ -50,6 +87,11 @@ export const OptionsList = ({ isFocused, onSelect, options, selectedOption, show
50
87
  useEffect(() => {
51
88
  setFocusedIndex(0);
52
89
  }, [questionKey]);
90
+ useEffect(() => {
91
+ if (focusedIndex > maxIndex) {
92
+ setFocusedIndex(maxIndex);
93
+ }
94
+ }, [focusedIndex, maxIndex]);
53
95
  // Detect recommended options and notify parent
54
96
  useEffect(() => {
55
97
  const recommendedOptions = options.filter((opt) => isRecommendedOption(opt.label));
@@ -72,7 +114,7 @@ export const OptionsList = ({ isFocused, onSelect, options, selectedOption, show
72
114
  if (isCustomInputFocused) {
73
115
  if (key.escape) {
74
116
  // Escape: Exit custom input mode and go back to option navigation
75
- setFocusedIndex(options.length - 1); // Focus on last option
117
+ setFocusedIndex(Math.max(0, options.length - 1)); // Focus on last option
76
118
  }
77
119
  return;
78
120
  }
@@ -141,11 +183,41 @@ export const OptionsList = ({ isFocused, onSelect, options, selectedOption, show
141
183
  : `${isFocusedOption ? ">" : " "} ${option.label}${isSelected ? " ✓" : ""}${starSuffix}`;
142
184
  return (React.createElement(Box, { key: index, flexDirection: "column" },
143
185
  React.createElement(Text, { backgroundColor: rowBg, bold: isFocusedOption || isSelected, color: rowColor }, fitRow(mainLine)),
144
- option.description && (React.createElement(Text, { backgroundColor: isFocusedOption
145
- ? theme.components.options.focusedBg
146
- : isSelected
147
- ? theme.components.options.selectedBg
148
- : undefined, color: theme.components.options.description, dimColor: !isFocusedOption && !isSelected }, fitRow(` ${option.description}`)))));
186
+ option.description &&
187
+ (() => {
188
+ const descText = ` ${option.description}`;
189
+ const descBg = isFocusedOption
190
+ ? theme.components.options.focusedBg
191
+ : isSelected
192
+ ? theme.components.options.selectedBg
193
+ : undefined;
194
+ const wouldWrap = descText.includes("\n") ||
195
+ getVisualWidth(descText) > rowWidth;
196
+ if (isFocusedOption && wouldWrap) {
197
+ // Focused + multi-line: show full description wrapped across lines
198
+ const wrappedLines = wrapText(descText, rowWidth);
199
+ return (React.createElement(Box, { flexDirection: "column" }, wrappedLines.map((line, lineIdx) => (React.createElement(Text, { key: lineIdx, backgroundColor: descBg, color: theme.components.options.description, dimColor: false }, padToVisualWidth(line, rowWidth))))));
200
+ }
201
+ else if (!isFocusedOption && wouldWrap) {
202
+ // Not focused + would wrap: truncate to 1 line with "..."
203
+ const maxWidth = rowWidth - 3; // Reserve 3 chars for "..."
204
+ let result = "";
205
+ let width = 0;
206
+ for (const char of descText) {
207
+ const charWidth = getVisualWidth(char);
208
+ if (width + charWidth > maxWidth)
209
+ break;
210
+ result += char;
211
+ width += charWidth;
212
+ }
213
+ const finalText = `${result}...`;
214
+ return (React.createElement(Text, { backgroundColor: descBg, color: theme.components.options.description, dimColor: true }, padToVisualWidth(finalText, rowWidth)));
215
+ }
216
+ else {
217
+ // Fits in 1 line (focused or not): show normally with padding
218
+ return (React.createElement(Text, { backgroundColor: descBg, color: theme.components.options.description, dimColor: !isFocusedOption && !isSelected }, fitRow(descText)));
219
+ }
220
+ })()));
149
221
  }),
150
222
  showCustomInput && (React.createElement(Box, { marginTop: 0 },
151
223
  React.createElement(Box, { flexDirection: "column" },
@@ -1,5 +1,5 @@
1
1
  import { Box, Text } from "ink";
2
- import React, { useState } from "react";
2
+ import React from "react";
3
3
  import { t } from "../../i18n/index.js";
4
4
  import { useTheme } from "../ThemeContext.js";
5
5
  import { Footer } from "./Footer.js";
@@ -10,13 +10,9 @@ import { TabBar } from "./TabBar.js";
10
10
  * QuestionDisplay shows a single question with its options
11
11
  * Includes TabBar, question prompt, options list, and footer
12
12
  */
13
- export const QuestionDisplay = ({ currentQuestion, currentQuestionIndex, customAnswer = "", elapsedLabel, onChangeCustomAnswer, onSelectOption, questions, selectedOption, onAdvanceToNext, answers, onToggleOption, multiSelect, onFocusContextChange, workingDirectory, onRecommendedDetected, hasRecommendedOptions, hasAnyRecommendedInSession, elaborateMarks, onElaborateSelect, elaborateText = "", onElaborateTextChange, }) => {
13
+ export const QuestionDisplay = ({ currentQuestion, currentQuestionIndex, customAnswer = "", showSessionSwitching, elapsedLabel, onChangeCustomAnswer, onSelectOption, questions, selectedOption, onAdvanceToNext, answers, onToggleOption, multiSelect, focusContext, onFocusContextChange, focusedOptionIndex, onFocusedOptionIndexChange, workingDirectory, onRecommendedDetected, hasRecommendedOptions, hasAnyRecommendedInSession, elaborateMarks, onElaborateSelect, elaborateText = "", onElaborateTextChange, }) => {
14
14
  const { theme } = useTheme();
15
- const [focusContext, setFocusContext] = useState("option");
16
- const handleFocusContextChange = (context) => {
17
- setFocusContext(context);
18
- onFocusContextChange?.(context);
19
- };
15
+ const FooterWithSessionSwitching = Footer;
20
16
  // Handle option selection - clears custom answer only in single-select mode
21
17
  const handleSelectOption = (label) => {
22
18
  onSelectOption(label);
@@ -48,6 +44,6 @@ export const QuestionDisplay = ({ currentQuestion, currentQuestionIndex, customA
48
44
  "]")),
49
45
  React.createElement(Box, null,
50
46
  React.createElement(Text, { color: theme.components.questionDisplay.elapsed, dimColor: true }, elapsedLabel))),
51
- React.createElement(OptionsList, { customValue: customAnswer, isFocused: true, onAdvance: onAdvanceToNext, onCustomChange: handleCustomAnswerChange, onSelect: handleSelectOption, options: currentQuestion.options, selectedOption: selectedOption, showCustomInput: true, onToggle: onToggleOption, multiSelect: multiSelect, selectedOptions: answers.get(currentQuestionIndex)?.selectedOptions, onFocusContextChange: handleFocusContextChange, onRecommendedDetected: onRecommendedDetected, questionKey: currentQuestionIndex, isElaborateMarked: elaborateMarks?.has(currentQuestionIndex), onElaborateSelect: onElaborateSelect, elaborateText: elaborateText, onElaborateTextChange: onElaborateTextChange }),
52
- React.createElement(Footer, { focusContext: focusContext, multiSelect: multiSelect ?? false, customInputValue: customAnswer, hasRecommendedOptions: hasRecommendedOptions, hasAnyRecommendedInSession: hasAnyRecommendedInSession })));
47
+ React.createElement(OptionsList, { customValue: customAnswer, isFocused: true, onAdvance: onAdvanceToNext, onCustomChange: handleCustomAnswerChange, onSelect: handleSelectOption, options: currentQuestion.options, selectedOption: selectedOption, showCustomInput: true, onToggle: onToggleOption, multiSelect: multiSelect, selectedOptions: answers.get(currentQuestionIndex)?.selectedOptions, onFocusContextChange: onFocusContextChange, focusedIndex: focusedOptionIndex, onFocusedIndexChange: onFocusedOptionIndexChange, onRecommendedDetected: onRecommendedDetected, questionKey: currentQuestionIndex, isElaborateMarked: elaborateMarks?.has(currentQuestionIndex), onElaborateSelect: onElaborateSelect, elaborateText: elaborateText, onElaborateTextChange: onElaborateTextChange }),
48
+ React.createElement(FooterWithSessionSwitching, { focusContext: focusContext, multiSelect: multiSelect ?? false, customInputValue: customAnswer, hasRecommendedOptions: hasRecommendedOptions, hasAnyRecommendedInSession: hasAnyRecommendedInSession, showSessionSwitching: showSessionSwitching })));
53
49
  };
@@ -0,0 +1,65 @@
1
+ import { Box, Text } from "ink";
2
+ import React from "react";
3
+ import { useTheme } from "../ThemeContext.js";
4
+ /* ------------------------------------------------------------------ */
5
+ /* Helpers */
6
+ /* ------------------------------------------------------------------ */
7
+ /** Check whether at least one answer in the map has meaningful content. */
8
+ function hasAnswers(answers) {
9
+ if (!answers || answers.size === 0)
10
+ return false;
11
+ for (const ans of answers.values()) {
12
+ if (ans.selectedOption || ans.customText)
13
+ return true;
14
+ if (ans.selectedOptions && ans.selectedOptions.length > 0)
15
+ return true;
16
+ }
17
+ return false;
18
+ }
19
+ /* ------------------------------------------------------------------ */
20
+ /* Component */
21
+ /* ------------------------------------------------------------------ */
22
+ /**
23
+ * SessionDots — a compact row of numbered dots rendered below the footer.
24
+ *
25
+ * Visual language:
26
+ * ● 1 ○ 2 ○ 3
27
+ *
28
+ * • Active session: filled ● + bold number in theme primary
29
+ * • Has answers: green (theme.success)
30
+ * • Touched/no answers: yellow (theme.warning)
31
+ * • Untouched: dim (theme.textDim)
32
+ */
33
+ export const SessionDots = ({ sessions, activeIndex, sessionUIStates, }) => {
34
+ const { theme } = useTheme();
35
+ // Don't render when fewer than 2 sessions
36
+ if (sessions.length < 2)
37
+ return null;
38
+ return (React.createElement(Box, { justifyContent: "center", paddingX: 1 }, sessions.map((session, idx) => {
39
+ const isActive = idx === activeIndex;
40
+ const uiState = sessionUIStates[session.sessionId];
41
+ // Determine the progress color for this session's dot
42
+ let dotColor;
43
+ if (isActive) {
44
+ dotColor = theme.components.sessionDots.active;
45
+ }
46
+ else if (uiState && hasAnswers(uiState.answers)) {
47
+ dotColor = theme.components.sessionDots.answered;
48
+ }
49
+ else if (uiState) {
50
+ dotColor = theme.components.sessionDots.inProgress;
51
+ }
52
+ else {
53
+ dotColor = theme.components.sessionDots.untouched;
54
+ }
55
+ const dot = isActive ? "●" : "○";
56
+ const numberColor = isActive
57
+ ? theme.components.sessionDots.activeNumber
58
+ : theme.components.sessionDots.number;
59
+ return (React.createElement(Box, { key: session.sessionId, paddingRight: idx < sessions.length - 1 ? 1 : 0 },
60
+ React.createElement(Text, { color: dotColor, bold: isActive }, dot),
61
+ React.createElement(Text, { color: numberColor, bold: isActive },
62
+ " ",
63
+ idx + 1)));
64
+ })));
65
+ };