auq-mcp-server 0.1.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 (66) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +176 -0
  3. package/dist/__tests__/schema-validation.test.js +137 -0
  4. package/dist/__tests__/server.integration.test.js +263 -0
  5. package/dist/add.js +1 -0
  6. package/dist/add.test.js +5 -0
  7. package/dist/bin/auq.js +245 -0
  8. package/dist/bin/test-session-menu.js +28 -0
  9. package/dist/bin/test-tabbar.js +42 -0
  10. package/dist/file-utils.js +59 -0
  11. package/dist/format/ResponseFormatter.js +206 -0
  12. package/dist/format/__tests__/ResponseFormatter.test.js +380 -0
  13. package/dist/package.json +74 -0
  14. package/dist/server.js +107 -0
  15. package/dist/session/ResponseFormatter.js +130 -0
  16. package/dist/session/SessionManager.js +474 -0
  17. package/dist/session/__tests__/ResponseFormatter.test.js +417 -0
  18. package/dist/session/__tests__/SessionManager.test.js +553 -0
  19. package/dist/session/__tests__/atomic-operations.test.js +345 -0
  20. package/dist/session/__tests__/file-watcher.test.js +311 -0
  21. package/dist/session/__tests__/workflow.integration.test.js +334 -0
  22. package/dist/session/atomic-operations.js +307 -0
  23. package/dist/session/file-watcher.js +218 -0
  24. package/dist/session/index.js +7 -0
  25. package/dist/session/types.js +20 -0
  26. package/dist/session/utils.js +125 -0
  27. package/dist/session-manager.js +171 -0
  28. package/dist/session-watcher.js +110 -0
  29. package/dist/src/__tests__/schema-validation.test.js +170 -0
  30. package/dist/src/__tests__/server.integration.test.js +274 -0
  31. package/dist/src/add.js +1 -0
  32. package/dist/src/add.test.js +5 -0
  33. package/dist/src/server.js +163 -0
  34. package/dist/src/session/ResponseFormatter.js +163 -0
  35. package/dist/src/session/SessionManager.js +572 -0
  36. package/dist/src/session/__tests__/ResponseFormatter.test.js +741 -0
  37. package/dist/src/session/__tests__/SessionManager.test.js +593 -0
  38. package/dist/src/session/__tests__/atomic-operations.test.js +346 -0
  39. package/dist/src/session/__tests__/file-watcher.test.js +311 -0
  40. package/dist/src/session/atomic-operations.js +307 -0
  41. package/dist/src/session/file-watcher.js +227 -0
  42. package/dist/src/session/index.js +7 -0
  43. package/dist/src/session/types.js +20 -0
  44. package/dist/src/session/utils.js +180 -0
  45. package/dist/src/tui/__tests__/session-watcher.test.js +368 -0
  46. package/dist/src/tui/components/AnimatedGradient.js +45 -0
  47. package/dist/src/tui/components/ConfirmationDialog.js +89 -0
  48. package/dist/src/tui/components/CustomInput.js +14 -0
  49. package/dist/src/tui/components/Footer.js +55 -0
  50. package/dist/src/tui/components/Header.js +35 -0
  51. package/dist/src/tui/components/MultiLineTextInput.js +65 -0
  52. package/dist/src/tui/components/OptionsList.js +115 -0
  53. package/dist/src/tui/components/QuestionDisplay.js +36 -0
  54. package/dist/src/tui/components/ReviewScreen.js +57 -0
  55. package/dist/src/tui/components/SessionSelectionMenu.js +151 -0
  56. package/dist/src/tui/components/StepperView.js +166 -0
  57. package/dist/src/tui/components/TabBar.js +42 -0
  58. package/dist/src/tui/components/Toast.js +19 -0
  59. package/dist/src/tui/components/WaitingScreen.js +20 -0
  60. package/dist/src/tui/session-watcher.js +195 -0
  61. package/dist/src/tui/theme.js +114 -0
  62. package/dist/src/tui/utils/gradientText.js +24 -0
  63. package/dist/tui/__tests__/session-watcher.test.js +368 -0
  64. package/dist/tui/session-watcher.js +183 -0
  65. package/package.json +78 -0
  66. package/scripts/postinstall.cjs +51 -0
@@ -0,0 +1,14 @@
1
+ import { Box, Text } from "ink";
2
+ import TextInput from "ink-text-input";
3
+ import React from "react";
4
+ /**
5
+ * CustomInput allows users to type free-text answers
6
+ * Uses Ink's TextInput component with visual focus indicator
7
+ */
8
+ export const CustomInput = ({ isFocused, onChange, value, }) => {
9
+ return (React.createElement(Box, { borderColor: isFocused ? "cyan" : "gray", borderStyle: "single", flexDirection: "column", marginTop: 1, padding: 0.5 },
10
+ React.createElement(Text, { dimColor: !isFocused },
11
+ isFocused ? "→" : " ",
12
+ " Custom answer: "),
13
+ React.createElement(Box, { marginTop: 0.5 }, isFocused ? (React.createElement(TextInput, { onChange: onChange, placeholder: "Type your answer here...", value: value })) : (React.createElement(Text, { color: value ? "white" : "gray" }, value || "(Press Tab to enter custom answer)")))));
14
+ };
@@ -0,0 +1,55 @@
1
+ import { Box, Text } from "ink";
2
+ import React from "react";
3
+ import { theme } from "../theme.js";
4
+ /**
5
+ * Footer component - displays context-aware keybindings
6
+ * Shows different shortcuts based on current focus context and question type
7
+ */
8
+ export const Footer = ({ focusContext, multiSelect, isReviewScreen = false, customInputValue = "", }) => {
9
+ const getKeybindings = () => {
10
+ // Review screen mode
11
+ if (isReviewScreen) {
12
+ return [
13
+ { key: "Enter", action: "Submit" },
14
+ { key: "n", action: "Back" },
15
+ ];
16
+ }
17
+ // Custom input focused
18
+ if (focusContext === "custom-input") {
19
+ const hasContent = customInputValue.trim().length > 0;
20
+ const bindings = [
21
+ { key: "↑↓", action: "Options" },
22
+ { key: "Tab", action: "Next" },
23
+ ];
24
+ if (hasContent) {
25
+ bindings.push({ key: "Enter", action: "Submit" });
26
+ }
27
+ bindings.push({ key: "Shift+Enter", action: "Newline" }, { key: "Esc", action: "Reject" });
28
+ return bindings;
29
+ }
30
+ // Option focused
31
+ if (focusContext === "option") {
32
+ const bindings = [
33
+ { key: "↑↓", action: "Options" },
34
+ { key: "←→", action: "Questions" },
35
+ ];
36
+ if (multiSelect) {
37
+ bindings.push({ key: "Space", action: "Toggle" }, { key: "Tab", action: "Submit" });
38
+ }
39
+ else {
40
+ bindings.push({ key: "Enter", action: "Select" });
41
+ }
42
+ bindings.push({ key: "Esc", action: "Reject" }, { key: "q", action: "Quit" });
43
+ return bindings;
44
+ }
45
+ return [];
46
+ };
47
+ const keybindings = getKeybindings();
48
+ return (React.createElement(Box, { borderColor: theme.borders.neutral, borderStyle: "single", paddingX: 1 },
49
+ React.createElement(Text, { dimColor: true }, keybindings.map((binding, idx) => (React.createElement(React.Fragment, { key: idx },
50
+ idx > 0 && React.createElement(Text, { dimColor: true }, " | "),
51
+ React.createElement(Text, { bold: true, color: "cyan" }, binding.key),
52
+ React.createElement(Text, { dimColor: true },
53
+ " ",
54
+ binding.action)))))));
55
+ };
@@ -0,0 +1,35 @@
1
+ import { Box, Text } from "ink";
2
+ import gradient from "gradient-string";
3
+ import React, { useEffect, useState } from "react";
4
+ import { theme } from "../theme.js";
5
+ /**
6
+ * Header component - displays app logo and status
7
+ * Shows at the top of the TUI with gradient branding and live-updating pending queue count
8
+ */
9
+ export const Header = ({ pendingCount }) => {
10
+ const [flash, setFlash] = useState(false);
11
+ const [prevCount, setPrevCount] = useState(pendingCount);
12
+ // Flash effect when count changes
13
+ useEffect(() => {
14
+ if (pendingCount !== prevCount) {
15
+ setFlash(true);
16
+ setPrevCount(pendingCount);
17
+ const timer = setTimeout(() => setFlash(false), 300);
18
+ return () => clearTimeout(timer);
19
+ }
20
+ }, [pendingCount, prevCount]);
21
+ // Use the selected gradient theme from theme.ts
22
+ const headerText = gradient[theme.headerGradient](".𖥔 AUQ ⋆ Ask User Questions MCP ⋆ ");
23
+ return (React.createElement(Box, { borderColor: theme.components.header.border, borderStyle: "single", flexDirection: "row", justifyContent: "space-between", paddingX: 1 },
24
+ React.createElement(Text, { bold: true }, headerText),
25
+ React.createElement(Box, null,
26
+ React.createElement(Text, { dimColor: true }, "\u2502"),
27
+ React.createElement(Text, { bold: flash, color: flash
28
+ ? theme.components.header.queueFlash
29
+ : pendingCount > 0
30
+ ? theme.components.header.queueActive
31
+ : theme.components.header.queueEmpty },
32
+ " ",
33
+ pendingCount,
34
+ " more on queue"))));
35
+ };
@@ -0,0 +1,65 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import React from "react";
3
+ import { theme } from "../theme.js";
4
+ /**
5
+ * Multi-line text input component for Ink
6
+ * Append-only mode: Shift+Enter for newlines, Enter to submit
7
+ * Note: No cursor positioning - text is append-only for simplicity
8
+ */
9
+ export const MultiLineTextInput = ({ isFocused = true, onChange, onSubmit, placeholder = "Type your answer...", value, }) => {
10
+ useInput((input, key) => {
11
+ if (!isFocused)
12
+ return;
13
+ // Normalize Enter key sequences that may arrive as raw input ("\r"/"\n").
14
+ // Prevent accidental carriage return insertion which causes line overwrite in terminals.
15
+ if (input === "\r" || input === "\n") {
16
+ if (key.shift) {
17
+ onChange(value + "\n");
18
+ }
19
+ else if (onSubmit && value.trim().length > 0) {
20
+ onSubmit();
21
+ }
22
+ return;
23
+ }
24
+ // Shift+Enter: Add newline
25
+ if (key.return && key.shift) {
26
+ onChange(value + "\n");
27
+ return;
28
+ }
29
+ // Enter: Submit (only if content is non-empty)
30
+ if (key.return) {
31
+ if (onSubmit && value.trim().length > 0) {
32
+ onSubmit();
33
+ }
34
+ return;
35
+ }
36
+ // Backspace: Remove last character
37
+ if (key.backspace || key.delete) {
38
+ onChange(value.slice(0, -1));
39
+ return;
40
+ }
41
+ // Regular character input (append)
42
+ if (input &&
43
+ !key.ctrl &&
44
+ !key.meta &&
45
+ !key.escape &&
46
+ input !== "\r" &&
47
+ input !== "\n") {
48
+ onChange(value + input);
49
+ }
50
+ }, { isActive: isFocused });
51
+ // Normalize any carriage returns that might already be present in value
52
+ const normalizedValue = value.replace(/\r\n?/g, "\n");
53
+ const hasContent = normalizedValue.length > 0;
54
+ const lines = hasContent ? normalizedValue.split("\n") : [placeholder];
55
+ return (React.createElement(Box, { flexDirection: "column" }, lines.map((line, index) => {
56
+ const isLastLine = index === lines.length - 1;
57
+ const showCursor = isFocused && isLastLine;
58
+ const isPlaceholder = !hasContent;
59
+ const lineHasContent = line.length > 0;
60
+ const displayText = lineHasContent ? line : showCursor ? "" : " ";
61
+ return (React.createElement(Text, { key: index, dimColor: isPlaceholder },
62
+ displayText,
63
+ showCursor && (React.createElement(Text, { color: theme.colors.focused, dimColor: true }, "\u258C"))));
64
+ })));
65
+ };
@@ -0,0 +1,115 @@
1
+ import { Box, Newline, Text, useInput } from "ink";
2
+ import React, { useEffect, useState } from "react";
3
+ import { theme } from "../theme.js";
4
+ import { MultiLineTextInput } from "./MultiLineTextInput.js";
5
+ /**
6
+ * OptionsList displays answer choices and handles arrow key navigation
7
+ * Uses ↑↓ to navigate, Enter to select
8
+ */
9
+ export const OptionsList = ({ isFocused, onSelect, options, selectedOption, showCustomInput = false, customValue = "", onCustomChange, onAdvance, multiSelect = false, onToggle, selectedOptions = [], onFocusContextChange, }) => {
10
+ const [focusedIndex, setFocusedIndex] = useState(0);
11
+ // Calculate max index: include custom input option if enabled
12
+ const maxIndex = showCustomInput ? options.length : options.length - 1;
13
+ const isCustomInputFocused = showCustomInput && focusedIndex === options.length;
14
+ const customLines = customValue.replace(/\r\n?/g, "\n").split("\n");
15
+ // Track and emit focus context changes
16
+ useEffect(() => {
17
+ const newContext = isCustomInputFocused
18
+ ? "custom-input"
19
+ : "option";
20
+ onFocusContextChange?.(newContext);
21
+ }, [focusedIndex, isCustomInputFocused, onFocusContextChange]);
22
+ useInput((input, key) => {
23
+ if (!isFocused)
24
+ return;
25
+ if (key.upArrow) {
26
+ setFocusedIndex((prev) => Math.max(0, prev - 1));
27
+ }
28
+ if (key.downArrow) {
29
+ setFocusedIndex((prev) => Math.min(maxIndex, prev + 1));
30
+ }
31
+ if (multiSelect) {
32
+ // Multi-select mode
33
+ if (input === " ") {
34
+ // Spacebar: Toggle selection WITHOUT advancing
35
+ if (!isCustomInputFocused) {
36
+ onToggle?.(options[focusedIndex].label);
37
+ }
38
+ }
39
+ if (key.return || key.tab) {
40
+ // Enter OR Tab: Advance to next question (don't toggle)
41
+ if (!isCustomInputFocused && onAdvance) {
42
+ onAdvance();
43
+ }
44
+ }
45
+ }
46
+ else {
47
+ // Single-select mode
48
+ if (key.return) {
49
+ // Don't handle Return when custom input is focused - MultiLineTextInput handles it
50
+ if (isCustomInputFocused) {
51
+ return;
52
+ }
53
+ // On regular option: select and advance
54
+ onSelect(options[focusedIndex].label);
55
+ if (onAdvance) {
56
+ onAdvance();
57
+ }
58
+ }
59
+ if (key.tab) {
60
+ // Tab: Just advance (don't select)
61
+ if (onAdvance) {
62
+ onAdvance();
63
+ }
64
+ }
65
+ }
66
+ }, { isActive: isFocused });
67
+ return (React.createElement(Box, { flexDirection: "column" },
68
+ options.map((option, index) => {
69
+ const isFocusedOption = isFocused && index === focusedIndex;
70
+ // Visual indicators
71
+ const indicator = isFocusedOption ? "→" : " ";
72
+ // Different icons for single vs multi-select
73
+ const isSelected = multiSelect
74
+ ? selectedOptions?.includes(option.label) || false
75
+ : selectedOption === option.label;
76
+ const selectionMark = multiSelect
77
+ ? isSelected
78
+ ? "[✔]"
79
+ : "[ ]" // Checkbox for multi-select
80
+ : isSelected
81
+ ? "●"
82
+ : "○"; // Radio for single-select
83
+ return (React.createElement(Box, { key: index, flexDirection: "column", marginTop: 0 },
84
+ React.createElement(Text, { bold: isFocusedOption || isSelected, color: isFocusedOption
85
+ ? theme.components.options.focused
86
+ : isSelected
87
+ ? theme.components.options.selected
88
+ : theme.components.options.default },
89
+ indicator,
90
+ " ",
91
+ selectionMark,
92
+ " ",
93
+ option.label),
94
+ option.description && (React.createElement(Box, { marginLeft: 4 },
95
+ React.createElement(Text, { color: isFocusedOption
96
+ ? theme.components.options.focused
97
+ : undefined, dimColor: !isFocusedOption }, option.description)))));
98
+ }),
99
+ showCustomInput && (React.createElement(Box, { marginTop: 0 },
100
+ React.createElement(Box, { flexDirection: "column" },
101
+ React.createElement(Text, { bold: isCustomInputFocused, color: isCustomInputFocused
102
+ ? theme.components.options.focused
103
+ : theme.components.options.default },
104
+ isCustomInputFocused ? "→" : " ",
105
+ " ",
106
+ customValue ? "●" : "○",
107
+ " Other (custom answer)"),
108
+ isCustomInputFocused && onCustomChange && (React.createElement(Box, { marginLeft: 2, marginTop: 0.5 },
109
+ React.createElement(MultiLineTextInput, { isFocused: true, onChange: onCustomChange, onSubmit: onAdvance, placeholder: "Type your answer... (Shift+Enter for newline)", value: customValue }))),
110
+ !isCustomInputFocused && customValue && (React.createElement(Box, { marginLeft: 2, marginTop: 0.5 },
111
+ React.createElement(Text, { dimColor: true }, customLines.map((line, idx) => (React.createElement(React.Fragment, { key: idx },
112
+ idx === 0 ? "❯ " : " ",
113
+ line || " ",
114
+ idx < customLines.length - 1 && React.createElement(Newline, null))))))))))));
115
+ };
@@ -0,0 +1,36 @@
1
+ import { Box, Text } from "ink";
2
+ import React, { useState } from "react";
3
+ import { Footer } from "./Footer.js";
4
+ import { OptionsList } from "./OptionsList.js";
5
+ import { TabBar } from "./TabBar.js";
6
+ /**
7
+ * QuestionDisplay shows a single question with its options
8
+ * Includes TabBar, question prompt, options list, and footer
9
+ */
10
+ export const QuestionDisplay = ({ currentQuestion, currentQuestionIndex, customAnswer = "", onChangeCustomAnswer, onSelectOption, questions, selectedOption, onAdvanceToNext, answers, onToggleOption, multiSelect, }) => {
11
+ // Track focus context for Footer component
12
+ const [focusContext, setFocusContext] = useState("option");
13
+ // Handle option selection - clears custom answer only in single-select mode
14
+ const handleSelectOption = (label) => {
15
+ onSelectOption(label);
16
+ if (customAnswer && !multiSelect) {
17
+ onChangeCustomAnswer(""); // Clear custom answer when option selected (single-select only)
18
+ }
19
+ };
20
+ // Handle custom answer change - clears option selection only in single-select mode
21
+ const handleCustomAnswerChange = (text) => {
22
+ onChangeCustomAnswer(text);
23
+ if (selectedOption && text && !multiSelect) {
24
+ onSelectOption(""); // Clear option when custom text entered (single-select only)
25
+ }
26
+ };
27
+ return (React.createElement(Box, { flexDirection: "column" },
28
+ React.createElement(TabBar, { currentIndex: currentQuestionIndex, questions: questions, answers: answers }),
29
+ React.createElement(Box, { marginTop: 1 },
30
+ React.createElement(Text, { bold: true },
31
+ currentQuestion.prompt,
32
+ " "),
33
+ React.createElement(Text, { dimColor: true }, multiSelect ? "[Multiple Choice]" : "[Single Choice]")),
34
+ 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: setFocusContext }),
35
+ React.createElement(Footer, { focusContext: focusContext, multiSelect: multiSelect ?? false, customInputValue: customAnswer })));
36
+ };
@@ -0,0 +1,57 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import React from "react";
3
+ import { theme } from "../theme.js";
4
+ import { Footer } from "./Footer.js";
5
+ /**
6
+ * ReviewScreen displays a summary of all answers for confirmation
7
+ * User can press Enter to confirm and submit, or 'n' to go back and edit
8
+ */
9
+ export const ReviewScreen = ({ answers, onConfirm, onGoBack, questions, }) => {
10
+ useInput((input, key) => {
11
+ if (key.return) {
12
+ // Convert answers to UserAnswer format
13
+ const userAnswers = [];
14
+ answers.forEach((answer, questionIndex) => {
15
+ if (answer.selectedOption || answer.selectedOptions || answer.customText) {
16
+ userAnswers.push({
17
+ customText: answer.customText,
18
+ questionIndex,
19
+ selectedOption: answer.selectedOption,
20
+ selectedOptions: answer.selectedOptions,
21
+ timestamp: new Date().toISOString(),
22
+ });
23
+ }
24
+ });
25
+ onConfirm(userAnswers);
26
+ }
27
+ if (input === "n") {
28
+ onGoBack();
29
+ }
30
+ });
31
+ return (React.createElement(Box, { flexDirection: "column" },
32
+ React.createElement(Box, { borderColor: theme.components.review.border, borderStyle: "single", marginBottom: 1, padding: 0.5 },
33
+ React.createElement(Text, { bold: true, color: theme.components.review.border }, "Review Your Answers")),
34
+ React.createElement(Box, { flexDirection: "column", marginBottom: 1 }, questions.map((question, index) => {
35
+ const answer = answers.get(index);
36
+ const questionTitle = question.title || `Q${index + 1}`;
37
+ return (React.createElement(Box, { flexDirection: "column", key: index, marginBottom: 1 },
38
+ React.createElement(Text, { bold: true },
39
+ questionTitle,
40
+ ". ",
41
+ question.prompt),
42
+ React.createElement(Box, { marginLeft: 2, marginTop: 0.5 },
43
+ answer?.selectedOptions && answer.selectedOptions.length > 0 && (React.createElement(Box, { flexDirection: "column" }, answer.selectedOptions.map((option, idx) => (React.createElement(Text, { key: idx, color: theme.components.review.selectedOption },
44
+ "\u2192 ",
45
+ option))))),
46
+ answer?.selectedOption && (React.createElement(Text, { color: theme.components.review.selectedOption },
47
+ "\u2192 ",
48
+ answer.selectedOption)),
49
+ answer?.customText && (React.createElement(Box, { flexDirection: "column" },
50
+ answer.customText.split("\n").map((line, lineIndex) => (React.createElement(Text, { key: lineIndex, color: theme.components.review.customAnswer }, lineIndex === 0
51
+ ? `→ Custom: "${line}`
52
+ : ` ${line}`))),
53
+ React.createElement(Text, { color: theme.components.review.customAnswer }, "\""))),
54
+ !answer?.selectedOption && !answer?.customText && (React.createElement(Text, { dimColor: true }, "\u2192 (No answer provided)")))));
55
+ })),
56
+ React.createElement(Footer, { focusContext: "option", multiSelect: false, isReviewScreen: true })));
57
+ };
@@ -0,0 +1,151 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, Text, useInput, useApp } from "ink";
3
+ import { createTUIWatcher } from "../session-watcher.js";
4
+ /**
5
+ * SessionSelectionMenu displays a list of pending question sets and allows user to select one
6
+ * Uses ↑↓ for navigation, Enter to select, q to quit
7
+ */
8
+ export const SessionSelectionMenu = ({ onSessionSelect, }) => {
9
+ const { exit } = useApp();
10
+ const [sessions, setSessions] = useState([]);
11
+ const [selectedIndex, setSelectedIndex] = useState(0);
12
+ const [isLoading, setIsLoading] = useState(true);
13
+ const [error, setError] = useState(null);
14
+ // Load pending sessions on mount and start persistent watcher
15
+ useEffect(() => {
16
+ let watcherInstance = null;
17
+ const initialize = async () => {
18
+ try {
19
+ setIsLoading(true);
20
+ // Step 1: Load existing pending sessions
21
+ const watcher = createTUIWatcher();
22
+ const sessionIds = await watcher.getPendingSessions();
23
+ const sessionData = await Promise.all(sessionIds.map(async (sessionId) => {
24
+ const sessionRequest = await watcher.getSessionRequest(sessionId);
25
+ if (!sessionRequest)
26
+ return null;
27
+ return {
28
+ sessionId,
29
+ sessionRequest,
30
+ timestamp: new Date(sessionRequest.timestamp),
31
+ };
32
+ }));
33
+ // Filter out null entries and sort by timestamp (newest first)
34
+ const validSessions = sessionData
35
+ .filter((s) => s !== null)
36
+ .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
37
+ setSessions(validSessions);
38
+ setIsLoading(false);
39
+ // Step 2: Start persistent watcher for new sessions
40
+ watcherInstance = createTUIWatcher({ autoLoadData: true });
41
+ watcherInstance.startEnhancedWatching((event) => {
42
+ // Add new session to queue (FIFO - append to end)
43
+ setSessions((prev) => {
44
+ // Check for duplicates
45
+ if (prev.some((s) => s.sessionId === event.sessionId)) {
46
+ return prev;
47
+ }
48
+ // Add to end of queue
49
+ return [
50
+ ...prev,
51
+ {
52
+ sessionId: event.sessionId,
53
+ sessionRequest: event.sessionRequest,
54
+ timestamp: new Date(event.timestamp),
55
+ },
56
+ ];
57
+ });
58
+ });
59
+ }
60
+ catch (err) {
61
+ setError(err instanceof Error ? err.message : "Failed to load question sets");
62
+ setIsLoading(false);
63
+ }
64
+ };
65
+ initialize();
66
+ // Cleanup: stop watcher on unmount
67
+ return () => {
68
+ if (watcherInstance) {
69
+ watcherInstance.stop();
70
+ }
71
+ };
72
+ }, []);
73
+ // Handle keyboard input
74
+ useInput((input, key) => {
75
+ if (isLoading)
76
+ return;
77
+ if (key.upArrow && sessions.length > 0) {
78
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
79
+ }
80
+ if (key.downArrow && sessions.length > 0) {
81
+ setSelectedIndex((prev) => Math.min(sessions.length - 1, prev + 1));
82
+ }
83
+ if (key.return && sessions[selectedIndex]) {
84
+ const { sessionId, sessionRequest } = sessions[selectedIndex];
85
+ onSessionSelect(sessionId, sessionRequest);
86
+ }
87
+ if (input === "q") {
88
+ exit();
89
+ }
90
+ });
91
+ // Loading state
92
+ if (isLoading) {
93
+ return (React.createElement(Box, { padding: 1 },
94
+ React.createElement(Text, null, "Loading question sets...")));
95
+ }
96
+ // Error state
97
+ if (error) {
98
+ return (React.createElement(Box, { flexDirection: "column", padding: 1 },
99
+ React.createElement(Text, { color: "red" },
100
+ "Error: ",
101
+ error),
102
+ React.createElement(Text, { dimColor: true }, "Press q to quit")));
103
+ }
104
+ // Zero sessions state
105
+ if (sessions.length === 0) {
106
+ return (React.createElement(Box, { flexDirection: "column", padding: 1 },
107
+ React.createElement(Text, { color: "yellow" }, "No pending question sets found."),
108
+ React.createElement(Text, { dimColor: true }, "Waiting for AI to ask questions..."),
109
+ React.createElement(Text, { dimColor: true }, "Press q to quit")));
110
+ }
111
+ // Session selection menu
112
+ return (React.createElement(Box, { flexDirection: "column", padding: 1, borderStyle: "round", borderColor: "cyan" },
113
+ React.createElement(Text, { bold: true }, "Select a pending question set:"),
114
+ React.createElement(Box, { marginTop: 1 }),
115
+ sessions.map((session, idx) => {
116
+ const isSelected = idx === selectedIndex;
117
+ const indicator = isSelected ? "→" : " ";
118
+ const questionCount = session.sessionRequest.questions.length;
119
+ const relativeTime = formatRelativeTime(session.sessionRequest.timestamp);
120
+ return (React.createElement(Text, { key: session.sessionId, color: isSelected ? "cyan" : "white" },
121
+ indicator,
122
+ " Question Set ",
123
+ idx + 1,
124
+ " (",
125
+ questionCount,
126
+ " ",
127
+ questionCount === 1 ? "question" : "questions",
128
+ ") - ",
129
+ relativeTime));
130
+ }),
131
+ React.createElement(Box, { marginTop: 1 }),
132
+ React.createElement(Text, { dimColor: true }, "\u2191\u2193 Navigate | Enter Select | q Quit")));
133
+ };
134
+ /**
135
+ * Format timestamp as relative time (e.g., "5m ago", "2h ago")
136
+ */
137
+ function formatRelativeTime(timestamp) {
138
+ const now = Date.now();
139
+ const then = new Date(timestamp).getTime();
140
+ const diffMs = now - then;
141
+ const diffMins = Math.floor(diffMs / 60000);
142
+ if (diffMins < 1)
143
+ return "just now";
144
+ if (diffMins < 60)
145
+ return `${diffMins}m ago`;
146
+ const diffHours = Math.floor(diffMins / 60);
147
+ if (diffHours < 24)
148
+ return `${diffHours}h ago`;
149
+ const diffDays = Math.floor(diffHours / 24);
150
+ return `${diffDays}d ago`;
151
+ }