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.
- package/LICENSE +25 -0
- package/README.md +176 -0
- package/dist/__tests__/schema-validation.test.js +137 -0
- package/dist/__tests__/server.integration.test.js +263 -0
- package/dist/add.js +1 -0
- package/dist/add.test.js +5 -0
- package/dist/bin/auq.js +245 -0
- package/dist/bin/test-session-menu.js +28 -0
- package/dist/bin/test-tabbar.js +42 -0
- package/dist/file-utils.js +59 -0
- package/dist/format/ResponseFormatter.js +206 -0
- package/dist/format/__tests__/ResponseFormatter.test.js +380 -0
- package/dist/package.json +74 -0
- package/dist/server.js +107 -0
- package/dist/session/ResponseFormatter.js +130 -0
- package/dist/session/SessionManager.js +474 -0
- package/dist/session/__tests__/ResponseFormatter.test.js +417 -0
- package/dist/session/__tests__/SessionManager.test.js +553 -0
- package/dist/session/__tests__/atomic-operations.test.js +345 -0
- package/dist/session/__tests__/file-watcher.test.js +311 -0
- package/dist/session/__tests__/workflow.integration.test.js +334 -0
- package/dist/session/atomic-operations.js +307 -0
- package/dist/session/file-watcher.js +218 -0
- package/dist/session/index.js +7 -0
- package/dist/session/types.js +20 -0
- package/dist/session/utils.js +125 -0
- package/dist/session-manager.js +171 -0
- package/dist/session-watcher.js +110 -0
- package/dist/src/__tests__/schema-validation.test.js +170 -0
- package/dist/src/__tests__/server.integration.test.js +274 -0
- package/dist/src/add.js +1 -0
- package/dist/src/add.test.js +5 -0
- package/dist/src/server.js +163 -0
- package/dist/src/session/ResponseFormatter.js +163 -0
- package/dist/src/session/SessionManager.js +572 -0
- package/dist/src/session/__tests__/ResponseFormatter.test.js +741 -0
- package/dist/src/session/__tests__/SessionManager.test.js +593 -0
- package/dist/src/session/__tests__/atomic-operations.test.js +346 -0
- package/dist/src/session/__tests__/file-watcher.test.js +311 -0
- package/dist/src/session/atomic-operations.js +307 -0
- package/dist/src/session/file-watcher.js +227 -0
- package/dist/src/session/index.js +7 -0
- package/dist/src/session/types.js +20 -0
- package/dist/src/session/utils.js +180 -0
- package/dist/src/tui/__tests__/session-watcher.test.js +368 -0
- package/dist/src/tui/components/AnimatedGradient.js +45 -0
- package/dist/src/tui/components/ConfirmationDialog.js +89 -0
- package/dist/src/tui/components/CustomInput.js +14 -0
- package/dist/src/tui/components/Footer.js +55 -0
- package/dist/src/tui/components/Header.js +35 -0
- package/dist/src/tui/components/MultiLineTextInput.js +65 -0
- package/dist/src/tui/components/OptionsList.js +115 -0
- package/dist/src/tui/components/QuestionDisplay.js +36 -0
- package/dist/src/tui/components/ReviewScreen.js +57 -0
- package/dist/src/tui/components/SessionSelectionMenu.js +151 -0
- package/dist/src/tui/components/StepperView.js +166 -0
- package/dist/src/tui/components/TabBar.js +42 -0
- package/dist/src/tui/components/Toast.js +19 -0
- package/dist/src/tui/components/WaitingScreen.js +20 -0
- package/dist/src/tui/session-watcher.js +195 -0
- package/dist/src/tui/theme.js +114 -0
- package/dist/src/tui/utils/gradientText.js +24 -0
- package/dist/tui/__tests__/session-watcher.test.js +368 -0
- package/dist/tui/session-watcher.js +183 -0
- package/package.json +78 -0
- 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
|
+
}
|