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.
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 +16 -3
  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 +55 -7
  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.1",
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;
@@ -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 [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);
@@ -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, { 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
+ };
@@ -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
+ };