auq-mcp-server 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/tui-app.js +199 -18
- package/dist/package.json +1 -1
- package/dist/src/i18n/locales/en.js +3 -0
- package/dist/src/i18n/locales/ko.js +3 -0
- package/dist/src/tui/components/Footer.js +6 -1
- package/dist/src/tui/components/OptionsList.js +81 -9
- package/dist/src/tui/components/QuestionDisplay.js +5 -9
- package/dist/src/tui/components/SessionDots.js +65 -0
- package/dist/src/tui/components/SessionPicker.js +159 -0
- package/dist/src/tui/components/StepperView.js +74 -9
- package/dist/src/tui/components/__tests__/SessionDots.test.js +92 -0
- package/dist/src/tui/components/__tests__/SessionPicker.test.js +125 -0
- package/dist/src/tui/components/__tests__/StepperView.state.test.js +101 -0
- package/dist/src/tui/themes/catppuccin-latte.js +18 -0
- package/dist/src/tui/themes/catppuccin-mocha.js +18 -0
- package/dist/src/tui/themes/dark.js +18 -0
- package/dist/src/tui/themes/dracula.js +18 -0
- package/dist/src/tui/themes/github-dark.js +18 -0
- package/dist/src/tui/themes/github-light.js +18 -0
- package/dist/src/tui/themes/gruvbox-dark.js +18 -0
- package/dist/src/tui/themes/gruvbox-light.js +18 -0
- package/dist/src/tui/themes/light.js +18 -0
- package/dist/src/tui/themes/monokai.js +18 -0
- package/dist/src/tui/themes/nord.js +18 -0
- package/dist/src/tui/themes/one-dark.js +18 -0
- package/dist/src/tui/themes/rose-pine.js +18 -0
- package/dist/src/tui/themes/solarized-dark.js +18 -0
- package/dist/src/tui/themes/solarized-light.js +18 -0
- package/dist/src/tui/themes/tokyo-night.js +18 -0
- package/dist/src/tui/types.js +1 -0
- package/dist/src/tui/utils/__tests__/relativeTime.test.js +31 -0
- package/dist/src/tui/utils/__tests__/sessionSwitching.test.js +82 -0
- package/dist/src/tui/utils/relativeTime.js +24 -0
- package/dist/src/tui/utils/sessionSwitching.js +56 -0
- package/package.json +1 -1
package/dist/bin/tui-app.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { Box, render, Text } from "ink";
|
|
2
|
-
import
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
164
|
-
|
|
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:
|
|
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
|
@@ -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 [
|
|
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 &&
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
|
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:
|
|
52
|
-
React.createElement(
|
|
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
|
+
};
|