auq-mcp-server 2.7.1 → 2.7.2

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 (33) hide show
  1. package/dist/package.json +3 -3
  2. package/dist/src/i18n/types.js +0 -1
  3. package/dist/src/tui/shared/session-events.js +0 -1
  4. package/dist/src/tui/shared/themes/types.js +0 -1
  5. package/dist/src/tui/shared/types.js +0 -1
  6. package/dist/src/tui-opentui/ConfigContext.js +10 -0
  7. package/dist/src/tui-opentui/ThemeProvider.js +73 -0
  8. package/dist/src/tui-opentui/app.js +536 -0
  9. package/dist/src/tui-opentui/components/AnimatedGradient.js +56 -0
  10. package/dist/src/tui-opentui/components/ConfirmationDialog.js +89 -0
  11. package/dist/src/tui-opentui/components/CustomInput.js +25 -0
  12. package/dist/src/tui-opentui/components/ErrorBoundary.js +26 -0
  13. package/dist/src/tui-opentui/components/Footer.js +92 -0
  14. package/dist/src/tui-opentui/components/Header.js +46 -0
  15. package/dist/src/tui-opentui/components/MarkdownPrompt.js +13 -0
  16. package/dist/src/tui-opentui/components/OptionsList.js +258 -0
  17. package/dist/src/tui-opentui/components/QuestionDisplay.js +23 -0
  18. package/dist/src/tui-opentui/components/ReviewScreen.js +81 -0
  19. package/dist/src/tui-opentui/components/SessionDots.js +86 -0
  20. package/dist/src/tui-opentui/components/SessionPicker.js +162 -0
  21. package/dist/src/tui-opentui/components/SingleLineTextInput.js +9 -0
  22. package/dist/src/tui-opentui/components/StepperView.js +493 -0
  23. package/dist/src/tui-opentui/components/TabBar.js +79 -0
  24. package/dist/src/tui-opentui/components/ThemeIndicator.js +35 -0
  25. package/dist/src/tui-opentui/components/Toast.js +44 -0
  26. package/dist/src/tui-opentui/components/UpdateBadge.js +24 -0
  27. package/dist/src/tui-opentui/components/UpdateOverlay.js +162 -0
  28. package/dist/src/tui-opentui/components/WaitingScreen.js +44 -0
  29. package/dist/src/tui-opentui/hooks/useSessionWatcher.js +69 -0
  30. package/dist/src/tui-opentui/hooks/useTerminalDimensions.js +8 -0
  31. package/dist/src/tui-opentui/utils/syntaxStyle.js +64 -0
  32. package/dist/src/update/types.js +0 -1
  33. package/package.json +3 -3
@@ -0,0 +1,56 @@
1
+ import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { useTheme } from "../ThemeProvider.js";
4
+ /** Hex string → [r, g, b] tuple */
5
+ function hexToRgb(hex) {
6
+ const h = hex.replace("#", "");
7
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
8
+ }
9
+ /** [r, g, b] tuple → hex string */
10
+ function rgbToHex(r, g, b) {
11
+ return ("#" +
12
+ [r, g, b]
13
+ .map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0"))
14
+ .join(""));
15
+ }
16
+ /** Linear interpolation between two hex colours */
17
+ function lerpColor(a, b, t) {
18
+ const [ar, ag, ab] = hexToRgb(a);
19
+ const [br, bg, bb] = hexToRgb(b);
20
+ return rgbToHex(ar + (br - ar) * t, ag + (bg - ag) * t, ab + (bb - ab) * t);
21
+ }
22
+ /**
23
+ * AnimatedGradient – flowing gradient text for OpenTUI.
24
+ *
25
+ * Renders each character in its own <text> element with a per-character
26
+ * foreground colour derived from the theme gradient palette, animated
27
+ * with a sine-wave shimmer at `fps` frames/sec.
28
+ *
29
+ * IMPORTANT: We do NOT use gradient-string here because that library
30
+ * produces ANSI escape sequences which OpenTUI renders as literal text.
31
+ * Instead each character gets its own <text style={{ fg: hexColor }}> leaf.
32
+ */
33
+ export const AnimatedGradient = ({ text, flowSpeed = 0.5, fps = 30, }) => {
34
+ const { theme } = useTheme();
35
+ const [frame, setFrame] = useState(0);
36
+ useEffect(() => {
37
+ const id = setInterval(() => {
38
+ setFrame((f) => f + 1);
39
+ }, 1000 / fps);
40
+ return () => clearInterval(id);
41
+ }, [fps]);
42
+ const chars = text.split("");
43
+ const total = chars.length || 1;
44
+ const elements = chars.map((char, i) => {
45
+ // Sine-wave phase: each char has a different phase offset, frame advances it
46
+ const phase = (i / total) * Math.PI * 2 + frame * flowSpeed * 0.1;
47
+ const t = (Math.sin(phase) + 1) / 2; // 0..1
48
+ // Interpolate start→middle in first half, middle→end in second half
49
+ const color = t < 0.5
50
+ ? lerpColor(theme.gradient.start, theme.gradient.middle, t * 2)
51
+ : lerpColor(theme.gradient.middle, theme.gradient.end, (t - 0.5) * 2);
52
+ return (_jsx("text", { style: { fg: color }, children: char }, i));
53
+ });
54
+ // <box flexDirection="row"> arranges the per-character <text> nodes side by side
55
+ return _jsx("box", { style: { flexDirection: "row" }, children: elements });
56
+ };
@@ -0,0 +1,89 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { TextAttributes } from "@opentui/core";
3
+ import { useState } from "react";
4
+ import { useKeyboard } from "@opentui/react";
5
+ import { t } from "../../i18n/index.js";
6
+ import { useTheme } from "../ThemeProvider.js";
7
+ import { KEYS } from "../../tui/constants/keybindings.js";
8
+ import { SingleLineTextInput } from "./SingleLineTextInput.js";
9
+ /**
10
+ * ConfirmationDialog shows a 3-option prompt for session rejection.
11
+ * Options: Reject & inform AI, Cancel, or Quit CLI.
12
+ * If user chooses to reject, shows a two-step flow to optionally collect rejection reason.
13
+ */
14
+ export const ConfirmationDialog = ({ message, onReject, onCancel, onQuit, }) => {
15
+ const { theme } = useTheme();
16
+ const [focusedIndex, setFocusedIndex] = useState(0);
17
+ const [showReasonInput, setShowReasonInput] = useState(false);
18
+ const [rejectionReason, setRejectionReason] = useState("");
19
+ const handleReasonSubmit = () => {
20
+ onReject(rejectionReason.trim() || null);
21
+ };
22
+ const handleSkipReason = () => {
23
+ onReject(null);
24
+ };
25
+ const options = [
26
+ {
27
+ key: "y",
28
+ label: t("confirmation.rejectYes"),
29
+ action: () => setShowReasonInput(true),
30
+ },
31
+ { key: "n", label: t("confirmation.rejectNo"), action: onCancel },
32
+ ];
33
+ useKeyboard((key) => {
34
+ // If in reason input mode, handle Esc to skip
35
+ if (showReasonInput) {
36
+ if (key.name === "escape") {
37
+ handleSkipReason();
38
+ }
39
+ return; // Let native input handle other keys
40
+ }
41
+ // Arrow key navigation
42
+ if (key.name === "up") {
43
+ setFocusedIndex((prev) => Math.max(0, prev - 1));
44
+ }
45
+ if (key.name === "down") {
46
+ setFocusedIndex((prev) => Math.min(options.length - 1, prev + 1));
47
+ }
48
+ // Enter key - select focused option
49
+ if (key.name === "return") {
50
+ options[focusedIndex].action();
51
+ }
52
+ // Letter shortcuts
53
+ if (KEYS.CONFIRM_YES.test(key.sequence || key.name)) {
54
+ setShowReasonInput(true);
55
+ }
56
+ if (KEYS.CONFIRM_NO.test(key.sequence || key.name)) {
57
+ onCancel();
58
+ }
59
+ // Esc key - same as quit
60
+ if (key.name === "escape") {
61
+ onQuit();
62
+ }
63
+ });
64
+ // Step 2: Reason input screen
65
+ if (showReasonInput) {
66
+ return (_jsxs("box", { style: {
67
+ borderColor: theme.borders.warning,
68
+ borderStyle: "rounded",
69
+ flexDirection: "column",
70
+ padding: 1,
71
+ }, children: [_jsx("box", { style: { marginBottom: 1 }, children: _jsx("text", { style: { attributes: TextAttributes.BOLD, fg: theme.colors.warning }, children: t("confirmation.rejectTitle") }) }), _jsx("box", { style: { marginBottom: 1 }, children: _jsx("text", { style: { attributes: TextAttributes.DIM }, children: t("confirmation.rejectMessage") }) }), _jsx("box", { style: { marginBottom: 1 }, children: _jsx(SingleLineTextInput, { isFocused: true, onChange: setRejectionReason, onSubmit: handleReasonSubmit, placeholder: "Type your reason here...", value: rejectionReason }) }), _jsx("box", { style: { marginTop: 1 }, children: _jsx("text", { style: { attributes: TextAttributes.DIM }, children: `Enter ${t("footer.submit")} | Esc ${t("footer.cancel")}` }) })] }));
72
+ }
73
+ // Step 1: Confirmation options
74
+ return (_jsxs("box", { style: {
75
+ borderColor: theme.borders.warning,
76
+ borderStyle: "rounded",
77
+ flexDirection: "column",
78
+ padding: 1,
79
+ }, children: [_jsx("box", { style: { marginBottom: 1 }, children: _jsx("text", { style: { attributes: TextAttributes.BOLD, fg: theme.colors.warning }, children: message }) }), options.map((option, index) => {
80
+ const isFocused = index === focusedIndex;
81
+ return (_jsx("box", { style: { marginTop: index > 0 ? 1 : 0 }, children: _jsx("text", { style: {
82
+ bg: isFocused
83
+ ? theme.components.options.focusedBg
84
+ : undefined,
85
+ attributes: isFocused ? TextAttributes.BOLD : TextAttributes.NONE,
86
+ fg: isFocused ? theme.colors.focused : theme.colors.text,
87
+ }, children: `${isFocused ? "> " : " "}${index + 1}. ${option.label} (${option.key})` }) }, index));
88
+ }), _jsx("box", { style: { marginTop: 1 }, children: _jsx("text", { style: { attributes: TextAttributes.DIM }, children: "\u2191\u2193 " + t("confirmation.keybindings") }) })] }));
89
+ };
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { TextAttributes } from "@opentui/core";
3
+ import { t } from "../../i18n/index.js";
4
+ import { useTheme } from "../ThemeProvider.js";
5
+ /**
6
+ * CustomInput allows users to type free-text answers.
7
+ * Uses OpenTUI's native <input> component with visual focus indicator.
8
+ */
9
+ export const CustomInput = ({ isFocused, onChange, value, }) => {
10
+ const { theme } = useTheme();
11
+ return (_jsxs("box", { style: {
12
+ borderColor: isFocused
13
+ ? theme.components.input.borderFocused
14
+ : theme.components.input.border,
15
+ borderStyle: "rounded",
16
+ flexDirection: "column",
17
+ marginTop: 1,
18
+ padding: 1,
19
+ }, children: [_jsx("text", { style: {
20
+ fg: theme.colors.textDim,
21
+ attributes: !isFocused ? TextAttributes.DIM : TextAttributes.NONE,
22
+ }, children: `${isFocused ? ">" : " "} ${t("input.customAnswerLabel")}` }), _jsx("box", { style: { marginTop: 1 }, children: isFocused ? (_jsx("input", { placeholder: "Type your answer here...", value: value, focused: isFocused, onInput: (val) => onChange(val) })) : (_jsx("text", { style: {
23
+ fg: value ? theme.colors.text : theme.colors.textDim,
24
+ }, children: value || t("input.customAnswerHint") })) })] }));
25
+ };
@@ -0,0 +1,26 @@
1
+ import React from "react";
2
+ /**
3
+ * Error boundary for OpenTUI renderer errors.
4
+ * On error, renders nothing (silent fail) — fallback handled at entry point level.
5
+ */
6
+ export class ErrorBoundary extends React.Component {
7
+ state = { hasError: false, error: null };
8
+ static getDerivedStateFromError(error) {
9
+ return { hasError: true, error };
10
+ }
11
+ componentDidCatch(error, info) {
12
+ // Always log to stderr so the error is visible even if rendering fails
13
+ process.stderr.write(`\n[OpenTUI Error] ${error.message}\n${error.stack ?? ""}\n`);
14
+ if (info?.componentStack) {
15
+ process.stderr.write(`Component stack: ${info.componentStack}\n`);
16
+ }
17
+ this.props.onError?.(error);
18
+ }
19
+ render() {
20
+ if (this.state.hasError) {
21
+ // Show error message instead of blank screen
22
+ return null;
23
+ }
24
+ return this.props.children;
25
+ }
26
+ }
@@ -0,0 +1,92 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { t } from "../../i18n/index.js";
4
+ import { useTheme } from "../ThemeProvider.js";
5
+ import { KEY_LABELS } from "../../tui/constants/keybindings.js";
6
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
+ /**
8
+ * Footer component — displays context-aware keybinding hints.
9
+ * Shows different shortcuts based on current focus context and question type.
10
+ */
11
+ export const Footer = ({ focusContext, multiSelect, isReviewScreen = false, showSessionSwitching = false, customInputValue: _customInputValue = "", hasRecommendedOptions = false, hasAnyRecommendedInSession = false, isSubmitting = false, hasUpdate = false, }) => {
12
+ const { theme } = useTheme();
13
+ const [spinnerFrame, setSpinnerFrame] = useState(0);
14
+ // Animate spinner when submitting
15
+ useEffect(() => {
16
+ if (!isSubmitting)
17
+ return;
18
+ const interval = setInterval(() => {
19
+ setSpinnerFrame((prev) => (prev + 1) % SPINNER_FRAMES.length);
20
+ }, 80);
21
+ return () => clearInterval(interval);
22
+ }, [isSubmitting]);
23
+ const getKeybindings = () => {
24
+ // Review screen mode
25
+ if (isReviewScreen) {
26
+ return [
27
+ { key: KEY_LABELS.SUBMIT, action: t("footer.submit") },
28
+ { key: KEY_LABELS.BACK, action: t("footer.back") },
29
+ ];
30
+ }
31
+ // Custom input focused
32
+ if (focusContext === "custom-input") {
33
+ return [
34
+ { key: KEY_LABELS.NAVIGATE_OPTIONS, action: t("footer.options") },
35
+ { key: KEY_LABELS.CURSOR, action: t("footer.cursor") },
36
+ { key: KEY_LABELS.NAVIGATE_QUESTIONS_TAB, action: t("footer.questions") },
37
+ { key: KEY_LABELS.NEWLINE, action: t("footer.newline") },
38
+ { key: KEY_LABELS.REJECT, action: t("footer.reject") },
39
+ ];
40
+ }
41
+ // Elaborate input focused (Enter skips, not newline)
42
+ if (focusContext === "elaborate-input") {
43
+ return [
44
+ { key: KEY_LABELS.NAVIGATE_OPTIONS, action: t("footer.options") },
45
+ { key: KEY_LABELS.CURSOR, action: t("footer.cursor") },
46
+ { key: "Enter/Tab", action: t("footer.next") },
47
+ { key: KEY_LABELS.REJECT, action: t("footer.reject") },
48
+ ];
49
+ }
50
+ // Option focused
51
+ if (focusContext === "option") {
52
+ const bindings = [
53
+ { key: KEY_LABELS.NAVIGATE_OPTIONS, action: t("footer.options") },
54
+ { key: KEY_LABELS.NAVIGATE_QUESTIONS, action: t("footer.questions") },
55
+ { key: KEY_LABELS.NAVIGATE_QUESTIONS_TAB, action: t("footer.questions") },
56
+ ];
57
+ if (multiSelect) {
58
+ bindings.push({ key: KEY_LABELS.SELECT, action: t("footer.toggle") });
59
+ bindings.push({ key: KEY_LABELS.NEXT, action: t("footer.next") });
60
+ }
61
+ else {
62
+ bindings.push({ key: KEY_LABELS.SELECT, action: t("footer.select") });
63
+ bindings.push({ key: KEY_LABELS.SELECT_NEXT, action: t("footer.selectNext") });
64
+ }
65
+ if (hasRecommendedOptions) {
66
+ bindings.push({ key: KEY_LABELS.RECOMMEND, action: t("footer.recommended") });
67
+ }
68
+ // Ctrl+R shows when ANY question in session has recommended (not just current)
69
+ if (hasAnyRecommendedInSession) {
70
+ bindings.push({ key: KEY_LABELS.QUICK_SUBMIT, action: t("footer.quickSubmit") });
71
+ }
72
+ if (showSessionSwitching) {
73
+ bindings.push({ key: KEY_LABELS.SESSION_SWITCH, action: t("footer.sessions") });
74
+ bindings.push({ key: "1-9", action: t("footer.jump") });
75
+ bindings.push({ key: KEY_LABELS.SESSION_LIST, action: t("footer.list") });
76
+ }
77
+ bindings.push({ key: KEY_LABELS.THEME, action: t("footer.theme") });
78
+ if (hasUpdate) {
79
+ bindings.push({ key: KEY_LABELS.UPDATE, action: "Update" });
80
+ }
81
+ bindings.push({ key: KEY_LABELS.REJECT, action: t("footer.reject") });
82
+ return bindings;
83
+ }
84
+ return [];
85
+ };
86
+ const keybindings = getKeybindings();
87
+ return (_jsx("box", { style: { paddingLeft: 2, paddingRight: 2, flexDirection: "row", flexWrap: "wrap", border: true, borderStyle: "rounded", borderColor: theme.colors.surface }, children: keybindings.map((binding, idx) => (_jsxs("box", { style: { paddingRight: 2 }, children: [_jsx("text", { style: {
88
+ bg: theme.components.footer.keyBg,
89
+ fg: theme.components.footer.keyFg,
90
+ bold: true,
91
+ }, children: ` ${binding.key} ` }), _jsx("text", { style: { fg: theme.components.footer.action, dim: true }, children: ` ${binding.action}` }), isSubmitting && binding.key === "Enter" && isReviewScreen ? (_jsx("text", { style: { fg: theme.colors.pending, bold: true }, children: ` ${SPINNER_FRAMES[spinnerFrame]}` })) : null] }, idx))) }));
92
+ };
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { t } from "../../i18n/index.js";
4
+ import packageJson from "../../../package.json" with { type: "json" };
5
+ import { useTheme } from "../ThemeProvider.js";
6
+ import { UpdateBadge as _UpdateBadge } from "./UpdateBadge.js";
7
+ // Cast to FC to avoid React type mismatch between @opentui/react bundled React
8
+ // and the project's @types/react (structural type incompatibility).
9
+ const UpdateBadge = _UpdateBadge;
10
+ /**
11
+ * Header component — displays app logo and status.
12
+ * Shows at the top of the TUI with solid accent branding and live-updating
13
+ * pending queue count pill.
14
+ */
15
+ export const Header = ({ pendingCount, isCheckingUpdate, updateInfo, onUpdateBadgeActivate: _onUpdateBadgeActivate, }) => {
16
+ const { theme } = useTheme();
17
+ const [flash, setFlash] = useState(false);
18
+ const [prevCount, setPrevCount] = useState(pendingCount);
19
+ // Flash effect when count changes
20
+ useEffect(() => {
21
+ if (pendingCount !== prevCount) {
22
+ setFlash(true);
23
+ setPrevCount(pendingCount);
24
+ const timer = setTimeout(() => setFlash(false), 300);
25
+ return () => clearTimeout(timer);
26
+ }
27
+ }, [pendingCount, prevCount]);
28
+ const version = useMemo(() => {
29
+ return packageJson.version || "unknown";
30
+ }, []);
31
+ const tagline = t("header.title");
32
+ // Queue pill color: flash > active > empty
33
+ const queueColor = flash
34
+ ? theme.components.header.queueFlash
35
+ : pendingCount > 0
36
+ ? theme.components.header.queueActive
37
+ : theme.components.header.queueEmpty;
38
+ const queueLabel = pendingCount > 0
39
+ ? ` ${pendingCount} queued `
40
+ : " idle ";
41
+ return (_jsxs("box", { style: { flexDirection: "row", justifyContent: "space-between", paddingLeft: 2, paddingRight: 2, border: true, borderStyle: "rounded", borderColor: theme.colors.surface }, children: [_jsxs("box", { style: { flexDirection: "row", alignItems: "center" }, children: [_jsx("text", { style: { bold: true, fg: theme.colors.primary }, children: "AUQ" }), _jsx("text", { style: { fg: "#8A949E" }, children: ` ⋆ ${tagline}` })] }), _jsxs("box", { style: { flexDirection: "row", alignItems: "center" }, children: [_jsx("text", { style: { fg: theme.colors.textDim }, children: `v${version}` }), updateInfo ? (_jsx(UpdateBadge, { updateType: updateInfo.updateType, latestVersion: updateInfo.latestVersion })) : null, _jsx("text", { style: { fg: theme.colors.textDim }, children: " " }), isCheckingUpdate ? (_jsx("text", { style: { fg: theme.colors.textDim }, children: " checking... " })) : null, _jsx("text", { style: {
42
+ bg: theme.components.header.pillBg,
43
+ fg: queueColor,
44
+ bold: flash,
45
+ }, children: queueLabel })] })] }));
46
+ };
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ import { useTheme } from "../ThemeProvider.js";
3
+ /**
4
+ * MarkdownPrompt renders markdown text using OpenTUI's native <markdown> element.
5
+ * Uses the theme-derived SyntaxStyle for code highlighting.
6
+ */
7
+ export const MarkdownPrompt = ({ text }) => {
8
+ const { syntaxStyle } = useTheme();
9
+ if (!text || text.trim().length === 0) {
10
+ return _jsx("text", { children: text });
11
+ }
12
+ return _jsx("markdown", { content: text, syntaxStyle: syntaxStyle });
13
+ };
@@ -0,0 +1,258 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { TextAttributes } from "@opentui/core";
3
+ import { useEffect, useState } from "react";
4
+ import { useKeyboard } from "@opentui/react";
5
+ import { t } from "../../i18n/index.js";
6
+ import { useConfig } from "../ConfigContext.js";
7
+ import { useTheme } from "../ThemeProvider.js";
8
+ import { isRecommendedOption } from "../../tui/shared/utils/recommended.js";
9
+ import { useTerminalDimensions } from "../hooks/useTerminalDimensions.js";
10
+ /**
11
+ * OptionsList displays answer choices and handles arrow key navigation.
12
+ * Uses \u2191\u2193 to navigate, Enter to select.
13
+ *
14
+ * Custom multi-select built with <box>, <text>, useKeyboard().
15
+ * NO native <select> \u2014 spec requires custom for multi-select.
16
+ */
17
+ 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, }) => {
18
+ const { theme } = useTheme();
19
+ const config = useConfig();
20
+ const { width: termWidth } = useTerminalDimensions();
21
+ const rowWidth = Math.max(20, termWidth - 6);
22
+ // Use prop if provided, otherwise use config value
23
+ const autoSelectRecommended = autoSelectRecommendedProp ?? config.autoSelectRecommended;
24
+ const [internalFocusedIndex, setInternalFocusedIndex] = useState(0);
25
+ const focusedIndex = focusedIndexProp ?? internalFocusedIndex;
26
+ const setFocusedIndex = (nextIndex) => {
27
+ const resolvedIndex = typeof nextIndex === "function" ? nextIndex(focusedIndex) : nextIndex;
28
+ if (focusedIndexProp === undefined) {
29
+ setInternalFocusedIndex(resolvedIndex);
30
+ }
31
+ onFocusedIndexChange?.(resolvedIndex);
32
+ };
33
+ // Calculate max index: include custom input and elaborate options if enabled
34
+ // Options: [0..n-1] = regular options, [n] = custom input, [n+1] = elaborate
35
+ const customInputIndex = options.length;
36
+ const elaborateIndex = options.length + 1;
37
+ const maxIndex = showCustomInput ? elaborateIndex : options.length - 1;
38
+ const isCustomInputFocused = showCustomInput && focusedIndex === customInputIndex;
39
+ const isElaborateFocused = showCustomInput && focusedIndex === elaborateIndex;
40
+ const customLines = customValue.replace(/\r\n?/g, "\n").split("\n");
41
+ const elaborateLines = elaborateText.replace(/\r\n?/g, "\n").split("\n");
42
+ // Track and emit focus context changes
43
+ useEffect(() => {
44
+ const newContext = isElaborateFocused
45
+ ? "elaborate-input"
46
+ : isCustomInputFocused
47
+ ? "custom-input"
48
+ : "option";
49
+ onFocusContextChange?.(newContext);
50
+ }, [
51
+ focusedIndex,
52
+ isCustomInputFocused,
53
+ isElaborateFocused,
54
+ onFocusContextChange,
55
+ ]);
56
+ // Reset focus when question changes
57
+ useEffect(() => {
58
+ setFocusedIndex(0);
59
+ }, [questionKey]);
60
+ useEffect(() => {
61
+ if (focusedIndex > maxIndex) {
62
+ setFocusedIndex(maxIndex);
63
+ }
64
+ }, [focusedIndex, maxIndex]);
65
+ // Detect recommended options and notify parent
66
+ useEffect(() => {
67
+ const recommendedOptions = options.filter((opt) => isRecommendedOption(opt.label));
68
+ const hasRecommended = recommendedOptions.length > 0;
69
+ onRecommendedDetected?.(hasRecommended);
70
+ }, [options, onRecommendedDetected]);
71
+ useKeyboard((key) => {
72
+ if (!isFocused)
73
+ return;
74
+ // Handle up/down navigation even when custom input is focused
75
+ if (key.name === "up") {
76
+ const newIndex = Math.max(0, focusedIndex - 1);
77
+ setFocusedIndex(newIndex);
78
+ return;
79
+ }
80
+ if (key.name === "down") {
81
+ const newIndex = Math.min(maxIndex, focusedIndex + 1);
82
+ setFocusedIndex(newIndex);
83
+ return;
84
+ }
85
+ // When custom input is focused, only handle escape and tab to exit
86
+ if (isCustomInputFocused) {
87
+ if (key.name === "escape") {
88
+ setFocusedIndex(Math.max(0, options.length - 1));
89
+ }
90
+ else if (key.name === "tab" && !key.shift) {
91
+ onAdvance?.();
92
+ }
93
+ return;
94
+ }
95
+ // When elaborate input is focused, only handle escape and tab to exit
96
+ if (isElaborateFocused) {
97
+ if (key.name === "escape") {
98
+ setFocusedIndex(customInputIndex);
99
+ }
100
+ else if (key.name === "tab" && !key.shift) {
101
+ onAdvance?.();
102
+ }
103
+ return;
104
+ }
105
+ // Spacebar: Select/toggle WITHOUT advancing (works for both modes)
106
+ if (key.name === "space") {
107
+ if (!isCustomInputFocused && !isElaborateFocused) {
108
+ if (multiSelect) {
109
+ onToggle?.(options[focusedIndex].label);
110
+ }
111
+ else {
112
+ onSelect(options[focusedIndex].label);
113
+ }
114
+ }
115
+ }
116
+ // Enter: Advance to next question
117
+ if (key.name === "return") {
118
+ if (isCustomInputFocused || isElaborateFocused) {
119
+ return;
120
+ }
121
+ if (multiSelect) {
122
+ // Multi-select: Enter just advances (spacebar toggles)
123
+ if (onAdvance) {
124
+ onAdvance();
125
+ }
126
+ }
127
+ else {
128
+ // Single-select: Enter selects AND advances
129
+ onSelect(options[focusedIndex].label);
130
+ if (onAdvance) {
131
+ onAdvance();
132
+ }
133
+ }
134
+ }
135
+ });
136
+ // ── Helper: Truncate text to fit row width ─────────────────────────────────────────
137
+ const fitRow = (text) => {
138
+ if (text.length > rowWidth) {
139
+ return text.slice(0, rowWidth - 1) + "\u2026";
140
+ }
141
+ return text; // Background is filled by parent <box>, no manual padding needed
142
+ };
143
+ return (_jsxs("box", { style: { flexDirection: "column" }, children: [options.map((option, index) => {
144
+ const isFocusedOption = isFocused && index === focusedIndex;
145
+ const isRecommended = isRecommendedOption(option.label);
146
+ // Different icons for single vs multi-select
147
+ const isSelected = multiSelect
148
+ ? selectedOptions?.includes(option.label) || false
149
+ : selectedOption === option.label;
150
+ const rowBg = isFocusedOption
151
+ ? theme.components.options.focusedBg
152
+ : isSelected
153
+ ? theme.components.options.selectedBg
154
+ : undefined;
155
+ const rowColor = isFocusedOption
156
+ ? theme.components.options.focused
157
+ : isSelected
158
+ ? theme.components.options.selected
159
+ : theme.components.options.default;
160
+ const starSuffix = isRecommended ? " \u2605" : "";
161
+ const mainLine = multiSelect
162
+ ? `${isFocusedOption ? ">" : " "} ${isSelected ? "[\u2713]" : "[ ]"} ${option.label}${starSuffix}`
163
+ : `${isFocusedOption ? ">" : " "} ${option.label}${isSelected ? " \u2713" : ""}${starSuffix}`;
164
+ return (_jsxs("box", { style: { flexDirection: "column" }, onMouseDown: () => {
165
+ setFocusedIndex(index);
166
+ if (multiSelect) {
167
+ onToggle?.(option.label);
168
+ }
169
+ else {
170
+ onSelect(option.label);
171
+ }
172
+ }, children: [_jsx("box", { style: { backgroundColor: rowBg }, children: _jsx("text", { style: {
173
+ attributes: (isFocusedOption || isSelected) ? TextAttributes.BOLD : TextAttributes.NONE,
174
+ fg: rowColor,
175
+ }, children: fitRow(mainLine) }) }), option.description && (_jsx("box", { style: { backgroundColor: isFocusedOption ? theme.components.options.focusedBg : isSelected ? theme.components.options.selectedBg : undefined }, children: _jsx("text", { style: {
176
+ fg: theme.components.options.description,
177
+ attributes: (!isFocusedOption && !isSelected) ? TextAttributes.DIM : TextAttributes.NONE,
178
+ }, children: fitRow(` ${option.description}`) }) }))] }, index));
179
+ }), showCustomInput && (_jsx("box", { style: { marginTop: 0 }, children: _jsxs("box", { style: { flexDirection: "column" }, children: [(() => {
180
+ const isSelected = customValue.trim().length > 0;
181
+ const rowBg = isCustomInputFocused
182
+ ? theme.components.options.focusedBg
183
+ : isSelected
184
+ ? theme.components.options.selectedBg
185
+ : undefined;
186
+ const rowColor = isCustomInputFocused
187
+ ? theme.components.options.focused
188
+ : isSelected
189
+ ? theme.components.options.selected
190
+ : theme.components.options.default;
191
+ const mainLine = multiSelect
192
+ ? `${isCustomInputFocused ? ">" : " "} ${isSelected ? "[\u2713]" : "[ ]"} ${t("input.otherCustom")}`
193
+ : `${isCustomInputFocused ? ">" : " "} ${t("input.otherCustom")}${isSelected ? " \u2713" : ""}`;
194
+ return (_jsx("box", { style: { backgroundColor: rowBg }, onMouseDown: () => { setFocusedIndex(customInputIndex); }, children: _jsx("text", { style: {
195
+ attributes: (isCustomInputFocused || isSelected) ? TextAttributes.BOLD : TextAttributes.NONE,
196
+ fg: rowColor,
197
+ }, children: fitRow(mainLine) }) }));
198
+ })(), isCustomInputFocused && onCustomChange && (_jsx("box", { style: {
199
+ borderColor: theme.components.input.borderFocused,
200
+ borderStyle: "rounded",
201
+ marginBottom: 1,
202
+ marginLeft: 2,
203
+ marginTop: 0,
204
+ paddingX: 1,
205
+ paddingY: 0,
206
+ }, children: _jsx("input", { placeholder: t("input.placeholder"), value: customValue, focused: true, onInput: (val) => {
207
+ // Filter SGR mouse escape sequences that leak through stdin parser
208
+ const sanitized = val
209
+ .replace(/\x1b?\[<[\d;]*[Mm]/g, '')
210
+ .replace(/\[?[OI]/g, '');
211
+ if (sanitized !== val && sanitized.length === 0)
212
+ return;
213
+ onCustomChange?.(sanitized);
214
+ }, onSubmit: () => onAdvance?.() }) })), !isCustomInputFocused && customValue && (_jsx("box", { style: { marginLeft: 2, marginTop: 0 }, children: _jsxs("text", { style: { fg: theme.components.options.hint, attributes: TextAttributes.DIM }, children: [" ", customLines.slice(0, 3).join("\n "), customLines.length > 3 ? "\n \u2026" : ""] }) }))] }) })), showCustomInput && (_jsx("box", { style: { marginTop: 0 }, children: _jsxs("box", { style: { flexDirection: "column" }, children: [(() => {
215
+ const rowBg = isElaborateFocused
216
+ ? theme.components.options.focusedBg
217
+ : isElaborateMarked
218
+ ? theme.components.options.selectedBg
219
+ : undefined;
220
+ const rowColor = isElaborateFocused
221
+ ? theme.components.options.focused
222
+ : isElaborateMarked
223
+ ? theme.colors.warning
224
+ : theme.components.options.default;
225
+ const mainLine = multiSelect
226
+ ? `${isElaborateFocused ? ">" : " "} ${isElaborateMarked ? "[\u2605]" : "[ ]"} ${t("footer.elaborate")}`
227
+ : `${isElaborateFocused ? ">" : " "} ${t("footer.elaborate")}${isElaborateMarked ? " \u2605" : ""}`;
228
+ return (_jsx("box", { style: { backgroundColor: rowBg }, onMouseDown: () => {
229
+ setFocusedIndex(elaborateIndex);
230
+ onElaborateSelect?.();
231
+ }, children: _jsx("text", { style: {
232
+ attributes: (isElaborateFocused || isElaborateMarked) ? TextAttributes.BOLD : TextAttributes.NONE,
233
+ fg: rowColor,
234
+ }, children: fitRow(mainLine) }) }));
235
+ })(), isElaborateFocused && onElaborateTextChange && (_jsx("box", { style: {
236
+ borderColor: theme.components.input.borderFocused,
237
+ borderStyle: "rounded",
238
+ marginBottom: 1,
239
+ marginLeft: 2,
240
+ marginTop: 0,
241
+ paddingX: 1,
242
+ paddingY: 0,
243
+ }, children: _jsx("input", { placeholder: t("input.elaboratePlaceholder"), value: elaborateText, focused: true, onInput: (val) => {
244
+ // Filter SGR mouse escape sequences that leak through stdin parser
245
+ const sanitized = val
246
+ .replace(/\x1b?\[<[\d;]*[Mm]/g, '')
247
+ .replace(/\[?[OI]/g, '');
248
+ if (sanitized !== val && sanitized.length === 0)
249
+ return;
250
+ onElaborateTextChange?.(sanitized);
251
+ }, onSubmit: () => {
252
+ // Enter submits and advance
253
+ if (!elaborateText.trim()) {
254
+ onElaborateSelect?.();
255
+ }
256
+ onAdvance?.();
257
+ } }) })), !isElaborateFocused && elaborateText && (_jsx("box", { style: { marginLeft: 2, marginTop: 0 }, children: _jsxs("text", { style: { fg: theme.components.options.hint, attributes: TextAttributes.DIM }, children: [" ", elaborateLines.slice(0, 3).join("\n "), elaborateLines.length > 3 ? "\n \u2026" : ""] }) }))] }) }))] }));
258
+ };
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { t } from "../../i18n/index.js";
3
+ import { useTheme } from "../ThemeProvider.js";
4
+ import { Footer } from "./Footer.js";
5
+ import { MarkdownPrompt } from "./MarkdownPrompt.js";
6
+ import { OptionsList } from "./OptionsList.js";
7
+ import { TabBar } from "./TabBar.js";
8
+ /**
9
+ * QuestionDisplay shows a single question with its options.
10
+ * Composes TabBar, MarkdownPrompt, OptionsList, and Footer.
11
+ */
12
+ 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, onSelectIndex, }) => {
13
+ const { theme } = useTheme();
14
+ // Handle option selection
15
+ const handleSelectOption = (label) => {
16
+ onSelectOption(label);
17
+ };
18
+ // Handle custom answer change
19
+ const handleCustomAnswerChange = (text) => {
20
+ onChangeCustomAnswer(text);
21
+ };
22
+ return (_jsxs("box", { style: { flexDirection: "column" }, children: [workingDirectory && (_jsxs("box", { style: { flexDirection: "row" }, children: [_jsx("text", { fg: theme.components.directory.label, children: "📁" }), _jsx("text", { style: { fg: theme.components.directory.path }, children: ` ${workingDirectory}` })] })), _jsx(TabBar, { currentIndex: currentQuestionIndex, questions: questions, answers: answers, elaborateMarks: elaborateMarks, onSelectIndex: onSelectIndex }), _jsxs("box", { style: { flexDirection: "column" }, children: [_jsxs("box", { style: { flexDirection: "row" }, children: [_jsx(MarkdownPrompt, { text: currentQuestion.prompt }), _jsx("text", { style: { fg: theme.components.questionDisplay.typeIndicator }, children: ` [${multiSelect ? t("question.multipleChoice") : t("question.singleChoice")}]` })] }), _jsx("box", { children: _jsx("text", { fg: theme.components.questionDisplay.elapsed, children: elapsedLabel }) })] }), _jsx(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 }), _jsx("box", { style: { marginTop: 1 }, children: _jsx(Footer, { focusContext: focusContext, multiSelect: multiSelect ?? false, customInputValue: customAnswer, hasRecommendedOptions: hasRecommendedOptions, hasAnyRecommendedInSession: hasAnyRecommendedInSession, showSessionSwitching: showSessionSwitching }) })] }));
23
+ };