auq-mcp-server 2.1.1 → 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 +16 -3
- 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 +55 -7
- 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;
|
|
@@ -11,12 +11,20 @@ import { MultiLineTextInput } from "./MultiLineTextInput.js";
|
|
|
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);
|
|
@@ -79,6 +87,11 @@ export const OptionsList = ({ isFocused, onSelect, options, selectedOption, show
|
|
|
79
87
|
useEffect(() => {
|
|
80
88
|
setFocusedIndex(0);
|
|
81
89
|
}, [questionKey]);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (focusedIndex > maxIndex) {
|
|
92
|
+
setFocusedIndex(maxIndex);
|
|
93
|
+
}
|
|
94
|
+
}, [focusedIndex, maxIndex]);
|
|
82
95
|
// Detect recommended options and notify parent
|
|
83
96
|
useEffect(() => {
|
|
84
97
|
const recommendedOptions = options.filter((opt) => isRecommendedOption(opt.label));
|
|
@@ -101,7 +114,7 @@ export const OptionsList = ({ isFocused, onSelect, options, selectedOption, show
|
|
|
101
114
|
if (isCustomInputFocused) {
|
|
102
115
|
if (key.escape) {
|
|
103
116
|
// Escape: Exit custom input mode and go back to option navigation
|
|
104
|
-
setFocusedIndex(options.length - 1); // Focus on last option
|
|
117
|
+
setFocusedIndex(Math.max(0, options.length - 1)); // Focus on last option
|
|
105
118
|
}
|
|
106
119
|
return;
|
|
107
120
|
}
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
2
|
+
import React, { useEffect, useState } from "react";
|
|
3
|
+
import { useTheme } from "../ThemeContext.js";
|
|
4
|
+
import { formatRelativeTime } from "../utils/relativeTime.js";
|
|
5
|
+
/* ------------------------------------------------------------------ */
|
|
6
|
+
/* Helpers */
|
|
7
|
+
/* ------------------------------------------------------------------ */
|
|
8
|
+
function countAnswered(answers) {
|
|
9
|
+
if (!answers)
|
|
10
|
+
return 0;
|
|
11
|
+
let count = 0;
|
|
12
|
+
for (const ans of answers.values()) {
|
|
13
|
+
if (ans.selectedOption || ans.customText) {
|
|
14
|
+
count++;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (ans.selectedOptions && ans.selectedOptions.length > 0)
|
|
18
|
+
count++;
|
|
19
|
+
}
|
|
20
|
+
return count;
|
|
21
|
+
}
|
|
22
|
+
function truncate(text, max) {
|
|
23
|
+
if (text.length <= max)
|
|
24
|
+
return text;
|
|
25
|
+
return text.slice(0, max - 1) + "…";
|
|
26
|
+
}
|
|
27
|
+
/* ------------------------------------------------------------------ */
|
|
28
|
+
/* Component */
|
|
29
|
+
/* ------------------------------------------------------------------ */
|
|
30
|
+
/**
|
|
31
|
+
* SessionPicker — a modal overlay listing all queued sessions.
|
|
32
|
+
*
|
|
33
|
+
* Opened via Ctrl+S. Each row shows:
|
|
34
|
+
* {index}. {title} — {workDir} [{answered}/{total}] {age}
|
|
35
|
+
*
|
|
36
|
+
* Navigation:
|
|
37
|
+
* ↑ / ↓ : move highlight
|
|
38
|
+
* Enter : select highlighted session
|
|
39
|
+
* Esc : close without switching
|
|
40
|
+
*/
|
|
41
|
+
export const SessionPicker = ({ isOpen, sessions, activeIndex, sessionUIStates, onSelectIndex, onClose, }) => {
|
|
42
|
+
const { theme } = useTheme();
|
|
43
|
+
const { stdout } = useStdout();
|
|
44
|
+
const termHeight = stdout?.rows ?? 24;
|
|
45
|
+
const termWidth = stdout?.columns ?? 80;
|
|
46
|
+
// ── Highlight state ──────────────────────────────────────────────
|
|
47
|
+
const [highlightIndex, setHighlightIndex] = useState(activeIndex);
|
|
48
|
+
// Reset highlight to active index each time the picker opens
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (isOpen) {
|
|
51
|
+
setHighlightIndex(activeIndex);
|
|
52
|
+
}
|
|
53
|
+
}, [isOpen, activeIndex]);
|
|
54
|
+
// ── Keyboard handling (only active when overlay is open) ────────
|
|
55
|
+
useInput((input, key) => {
|
|
56
|
+
if (key.upArrow) {
|
|
57
|
+
setHighlightIndex((prev) => Math.max(0, prev - 1));
|
|
58
|
+
}
|
|
59
|
+
else if (key.downArrow) {
|
|
60
|
+
setHighlightIndex((prev) => Math.min(sessions.length - 1, prev + 1));
|
|
61
|
+
}
|
|
62
|
+
else if (key.return) {
|
|
63
|
+
onSelectIndex(highlightIndex);
|
|
64
|
+
onClose();
|
|
65
|
+
}
|
|
66
|
+
else if (key.escape) {
|
|
67
|
+
onClose();
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Direct number jump (1-9)
|
|
71
|
+
const num = parseInt(input, 10);
|
|
72
|
+
if (num >= 1 && num <= sessions.length) {
|
|
73
|
+
onSelectIndex(num - 1);
|
|
74
|
+
onClose();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}, { isActive: isOpen });
|
|
78
|
+
// ── Render nothing when closed ──────────────────────────────────
|
|
79
|
+
if (!isOpen)
|
|
80
|
+
return null;
|
|
81
|
+
// ── Scrolling logic ─────────────────────────────────────────────
|
|
82
|
+
// Reserve lines for border (2), title (1), padding (2), footer hint (2)
|
|
83
|
+
const chromeLines = 7;
|
|
84
|
+
const maxVisibleRows = Math.max(1, termHeight - chromeLines);
|
|
85
|
+
const needsScroll = sessions.length > maxVisibleRows;
|
|
86
|
+
let scrollOffset = 0;
|
|
87
|
+
if (needsScroll) {
|
|
88
|
+
// Keep highlighted row centred in the visible window
|
|
89
|
+
scrollOffset = Math.max(0, Math.min(highlightIndex - Math.floor(maxVisibleRows / 2), sessions.length - maxVisibleRows));
|
|
90
|
+
}
|
|
91
|
+
const visibleSessions = sessions.slice(scrollOffset, scrollOffset + maxVisibleRows);
|
|
92
|
+
// ── Derive max label widths from terminal size ──────────────────
|
|
93
|
+
// Layout: " {idx}. {title} — {dir} [{x}/{y}] {age} "
|
|
94
|
+
// We cap the variable-width parts to fit a single line.
|
|
95
|
+
const innerWidth = Math.max(30, termWidth - 6); // 6 for border + padding
|
|
96
|
+
const fixedOverhead = 18; // " 1. " + " — " + " [x/y] " + " Xm ago"
|
|
97
|
+
const dynamicWidth = Math.max(10, innerWidth - fixedOverhead);
|
|
98
|
+
const titleMax = Math.max(6, Math.floor(dynamicWidth * 0.55));
|
|
99
|
+
const dirMax = Math.max(4, dynamicWidth - titleMax);
|
|
100
|
+
return (React.createElement(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center" },
|
|
101
|
+
React.createElement(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.components.sessionPicker.border, paddingX: 2, paddingY: 1, width: Math.min(innerWidth + 6, termWidth) },
|
|
102
|
+
React.createElement(Box, { justifyContent: "center", marginBottom: 1 },
|
|
103
|
+
React.createElement(Text, { bold: true, color: theme.components.sessionPicker.title }, "Switch Session"),
|
|
104
|
+
React.createElement(Text, { color: theme.components.sessionPicker.rowDim },
|
|
105
|
+
" ",
|
|
106
|
+
"(",
|
|
107
|
+
sessions.length,
|
|
108
|
+
" queued)")),
|
|
109
|
+
needsScroll && scrollOffset > 0 && (React.createElement(Box, { justifyContent: "center" },
|
|
110
|
+
React.createElement(Text, { color: theme.components.sessionPicker.rowDim }, "\u25B2 more"))),
|
|
111
|
+
visibleSessions.map((session, visibleIdx) => {
|
|
112
|
+
const realIdx = scrollOffset + visibleIdx;
|
|
113
|
+
const isHighlighted = realIdx === highlightIndex;
|
|
114
|
+
const isActive = realIdx === activeIndex;
|
|
115
|
+
const uiState = sessionUIStates[session.sessionId];
|
|
116
|
+
const questions = session.sessionRequest.questions;
|
|
117
|
+
const title = truncate(questions[0]?.title || "Untitled", titleMax);
|
|
118
|
+
const dir = truncate(session.sessionRequest.workingDirectory || "unknown", dirMax);
|
|
119
|
+
const total = questions.length;
|
|
120
|
+
const answered = countAnswered(uiState?.answers);
|
|
121
|
+
const age = formatRelativeTime(session.timestamp);
|
|
122
|
+
// Row colors
|
|
123
|
+
const rowBg = isHighlighted
|
|
124
|
+
? theme.components.sessionPicker.highlightBg
|
|
125
|
+
: undefined;
|
|
126
|
+
const textColor = isHighlighted
|
|
127
|
+
? theme.components.sessionPicker.highlightFg
|
|
128
|
+
: isActive
|
|
129
|
+
? theme.components.sessionPicker.activeMark
|
|
130
|
+
: theme.components.sessionPicker.rowText;
|
|
131
|
+
// Progress color
|
|
132
|
+
const progressColor = answered === total && total > 0
|
|
133
|
+
? theme.components.sessionPicker.progress
|
|
134
|
+
: answered > 0
|
|
135
|
+
? theme.components.sessionPicker.progress
|
|
136
|
+
: theme.components.sessionPicker.rowDim;
|
|
137
|
+
return (React.createElement(Box, { key: session.sessionId },
|
|
138
|
+
React.createElement(Text, { backgroundColor: rowBg, bold: isHighlighted, color: textColor },
|
|
139
|
+
isActive ? "►" : " ",
|
|
140
|
+
" ",
|
|
141
|
+
realIdx + 1,
|
|
142
|
+
". ",
|
|
143
|
+
title),
|
|
144
|
+
React.createElement(Text, { backgroundColor: rowBg, color: theme.components.sessionPicker.rowDim },
|
|
145
|
+
" — ",
|
|
146
|
+
dir),
|
|
147
|
+
React.createElement(Text, { backgroundColor: rowBg, color: progressColor },
|
|
148
|
+
" [",
|
|
149
|
+
answered,
|
|
150
|
+
"/",
|
|
151
|
+
total,
|
|
152
|
+
"]"),
|
|
153
|
+
React.createElement(Text, { backgroundColor: rowBg, color: theme.components.sessionPicker.rowDim, dimColor: true }, age)));
|
|
154
|
+
}),
|
|
155
|
+
needsScroll && scrollOffset + maxVisibleRows < sessions.length && (React.createElement(Box, { justifyContent: "center" },
|
|
156
|
+
React.createElement(Text, { color: theme.components.sessionPicker.rowDim }, "\u25BC more"))),
|
|
157
|
+
React.createElement(Box, { justifyContent: "center", marginTop: 1 },
|
|
158
|
+
React.createElement(Text, { color: theme.components.sessionPicker.rowDim, dimColor: true }, "\u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc close \u00B7 1-9 jump")))));
|
|
159
|
+
};
|