auq-mcp-server 2.2.2 → 2.4.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/README.md +82 -0
- package/dist/bin/auq.js +45 -39
- package/dist/bin/tui-app.js +78 -8
- package/dist/package.json +1 -1
- package/dist/src/__tests__/server.abort.test.js +214 -0
- package/dist/src/cli/commands/__tests__/answer.test.js +199 -0
- package/dist/src/cli/commands/__tests__/config.test.js +218 -0
- package/dist/src/cli/commands/__tests__/sessions.test.js +282 -0
- package/dist/src/cli/commands/answer.js +128 -0
- package/dist/src/cli/commands/config.js +263 -0
- package/dist/src/cli/commands/sessions.js +164 -0
- package/dist/src/cli/utils.js +95 -0
- package/dist/src/config/__tests__/ConfigLoader.test.js +41 -0
- package/dist/src/config/defaults.js +3 -0
- package/dist/src/config/types.js +4 -0
- package/dist/src/core/ask-user-questions.js +3 -2
- package/dist/src/i18n/locales/en.js +8 -1
- package/dist/src/i18n/locales/ko.js +8 -1
- package/dist/src/server.js +64 -11
- package/dist/src/session/SessionManager.js +69 -4
- package/dist/src/session/__tests__/SessionManager.test.js +65 -0
- package/dist/src/tui/ThemeProvider.js +2 -1
- package/dist/src/tui/__tests__/session-watcher.test.js +109 -0
- package/dist/src/tui/components/ConfirmationDialog.js +5 -4
- package/dist/src/tui/components/Footer.js +24 -23
- package/dist/src/tui/components/ReviewScreen.js +2 -1
- package/dist/src/tui/components/SessionDots.js +33 -4
- package/dist/src/tui/components/SessionPicker.js +27 -18
- package/dist/src/tui/components/Spinner.js +19 -0
- package/dist/src/tui/components/StepperView.js +71 -7
- package/dist/src/tui/components/WaitingScreen.js +2 -1
- package/dist/src/tui/components/__tests__/ConfirmationDialog.test.js +134 -0
- package/dist/src/tui/components/__tests__/Footer.test.js +121 -0
- package/dist/src/tui/components/__tests__/ReviewScreen.test.js +89 -0
- package/dist/src/tui/components/__tests__/SessionDots.test.js +160 -1
- package/dist/src/tui/components/__tests__/SessionPicker.test.js +43 -1
- package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +160 -0
- package/dist/src/tui/components/__tests__/StepperView.keyboard.test.js +135 -0
- package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -0
- package/dist/src/tui/components/__tests__/WaitingScreen.test.js +60 -0
- package/dist/src/tui/constants/keybindings.js +40 -0
- package/dist/src/tui/session-watcher.js +50 -0
- package/dist/src/tui/themes/catppuccin-latte.js +7 -0
- package/dist/src/tui/themes/catppuccin-mocha.js +7 -0
- package/dist/src/tui/themes/dark.js +7 -0
- package/dist/src/tui/themes/dracula.js +7 -0
- package/dist/src/tui/themes/github-dark.js +7 -0
- package/dist/src/tui/themes/github-light.js +7 -0
- package/dist/src/tui/themes/gruvbox-dark.js +7 -0
- package/dist/src/tui/themes/gruvbox-light.js +7 -0
- package/dist/src/tui/themes/light.js +7 -0
- package/dist/src/tui/themes/monokai.js +7 -0
- package/dist/src/tui/themes/nord.js +7 -0
- package/dist/src/tui/themes/one-dark.js +7 -0
- package/dist/src/tui/themes/rose-pine.js +7 -0
- package/dist/src/tui/themes/solarized-dark.js +7 -0
- package/dist/src/tui/themes/solarized-light.js +7 -0
- package/dist/src/tui/themes/tokyo-night.js +7 -0
- package/dist/src/tui/utils/__tests__/detectTheme.test.js +78 -0
- package/dist/src/tui/utils/__tests__/staleDetection.test.js +118 -0
- package/dist/src/tui/utils/staleDetection.js +51 -0
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from "react";
|
|
|
3
3
|
import { AnimatedGradient } from "./AnimatedGradient.js";
|
|
4
4
|
import { useTheme } from "../ThemeContext.js";
|
|
5
5
|
import { t } from "../../i18n/index.js";
|
|
6
|
+
import { KEYS } from "../constants/keybindings.js";
|
|
6
7
|
/**
|
|
7
8
|
* WaitingScreen displays when no question sets are being processed
|
|
8
9
|
* Shows "Waiting for AI..." message or queue status
|
|
@@ -22,7 +23,7 @@ export const WaitingScreen = ({ queueCount }) => {
|
|
|
22
23
|
}, [startTime]);
|
|
23
24
|
// Handle 'q' key to quit
|
|
24
25
|
useInput((input, key) => {
|
|
25
|
-
if (input
|
|
26
|
+
if (KEYS.QUIT.test(input)) {
|
|
26
27
|
process.exit(0);
|
|
27
28
|
}
|
|
28
29
|
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cleanup, render } from "ink-testing-library";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
const inputState = vi.hoisted(() => ({
|
|
5
|
+
handler: null,
|
|
6
|
+
}));
|
|
7
|
+
vi.mock("ink", async () => {
|
|
8
|
+
const actual = await vi.importActual("ink");
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
useInput: (handler, options) => {
|
|
12
|
+
// ConfirmationDialog calls useInput without options, so always capture
|
|
13
|
+
if (!options || options.isActive !== false) {
|
|
14
|
+
inputState.handler = handler;
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
import { ThemeContext } from "../../ThemeContext.js";
|
|
20
|
+
import { darkTheme } from "../../themes/dark.js";
|
|
21
|
+
import { ConfirmationDialog } from "../ConfirmationDialog.js";
|
|
22
|
+
const mockThemeValue = {
|
|
23
|
+
theme: darkTheme,
|
|
24
|
+
themeName: "AUQ dark",
|
|
25
|
+
cycleTheme: () => { },
|
|
26
|
+
};
|
|
27
|
+
function renderWithTheme(ui) {
|
|
28
|
+
return render(React.createElement(ThemeContext.Provider, { value: mockThemeValue }, ui));
|
|
29
|
+
}
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
cleanup();
|
|
32
|
+
inputState.handler = null;
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
});
|
|
35
|
+
describe("ConfirmationDialog keyboard handling", () => {
|
|
36
|
+
const defaultProps = {
|
|
37
|
+
message: "Test confirmation",
|
|
38
|
+
onReject: vi.fn(),
|
|
39
|
+
onCancel: vi.fn(),
|
|
40
|
+
onQuit: vi.fn(),
|
|
41
|
+
};
|
|
42
|
+
function renderDialog(overrides = {}) {
|
|
43
|
+
const props = {
|
|
44
|
+
...defaultProps,
|
|
45
|
+
onReject: vi.fn(),
|
|
46
|
+
onCancel: vi.fn(),
|
|
47
|
+
onQuit: vi.fn(),
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
const instance = renderWithTheme(React.createElement(ConfirmationDialog, { ...props }));
|
|
51
|
+
return { instance, ...props };
|
|
52
|
+
}
|
|
53
|
+
it("arrow down at last item stays at last item (clamping)", async () => {
|
|
54
|
+
const { onCancel, instance } = renderDialog();
|
|
55
|
+
expect(inputState.handler).not.toBeNull();
|
|
56
|
+
// There are 2 options (index 0 and 1). Press down to go to last.
|
|
57
|
+
inputState.handler("", { downArrow: true });
|
|
58
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
59
|
+
// Press down again — should clamp at last item, not wrap to first
|
|
60
|
+
inputState.handler("", { downArrow: true });
|
|
61
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
62
|
+
// Press down a third time — still clamped at last
|
|
63
|
+
inputState.handler("", { downArrow: true });
|
|
64
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
65
|
+
// Now press Enter — should select the last option (index 1 = onCancel)
|
|
66
|
+
inputState.handler("", { return: true });
|
|
67
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
68
|
+
// The second option's action is onCancel
|
|
69
|
+
expect(onCancel).toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
it("arrow up at first item stays at first item (clamping)", async () => {
|
|
72
|
+
const { onCancel } = renderDialog();
|
|
73
|
+
expect(inputState.handler).not.toBeNull();
|
|
74
|
+
// At index 0, press up multiple times
|
|
75
|
+
inputState.handler("", { upArrow: true });
|
|
76
|
+
await Promise.resolve();
|
|
77
|
+
inputState.handler("", { upArrow: true });
|
|
78
|
+
await Promise.resolve();
|
|
79
|
+
// Press Enter — should select first option (index 0 = setShowReasonInput)
|
|
80
|
+
// The first option's action is setShowReasonInput(true), not onCancel
|
|
81
|
+
inputState.handler("", { return: true });
|
|
82
|
+
await Promise.resolve();
|
|
83
|
+
// onCancel should NOT have been called (that's the second option)
|
|
84
|
+
expect(onCancel).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
it("Enter selects focused option", async () => {
|
|
87
|
+
const { onCancel } = renderDialog();
|
|
88
|
+
expect(inputState.handler).not.toBeNull();
|
|
89
|
+
// Default focus is index 0 (Yes option). Press Enter.
|
|
90
|
+
inputState.handler("", { return: true });
|
|
91
|
+
await Promise.resolve();
|
|
92
|
+
// First option sets showReasonInput=true, does NOT call onCancel
|
|
93
|
+
expect(onCancel).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
it("y key triggers yes action", async () => {
|
|
96
|
+
renderDialog();
|
|
97
|
+
expect(inputState.handler).not.toBeNull();
|
|
98
|
+
// Press 'y' — should trigger CONFIRM_YES shortcut (setShowReasonInput)
|
|
99
|
+
inputState.handler("y", {});
|
|
100
|
+
await Promise.resolve();
|
|
101
|
+
// After pressing y, the component transitions to reason input mode
|
|
102
|
+
// We verify it didn't call onCancel or onQuit
|
|
103
|
+
// (The actual transition to reason input is internal state)
|
|
104
|
+
});
|
|
105
|
+
it("Y key (uppercase) triggers yes action (case-insensitive)", async () => {
|
|
106
|
+
renderDialog();
|
|
107
|
+
expect(inputState.handler).not.toBeNull();
|
|
108
|
+
// Press 'Y' — KEYS.CONFIRM_YES is /^[yY]$/, should match
|
|
109
|
+
inputState.handler("Y", {});
|
|
110
|
+
await Promise.resolve();
|
|
111
|
+
// Should work same as lowercase 'y'
|
|
112
|
+
});
|
|
113
|
+
it("n key triggers cancel action", async () => {
|
|
114
|
+
const { onCancel } = renderDialog();
|
|
115
|
+
expect(inputState.handler).not.toBeNull();
|
|
116
|
+
inputState.handler("n", {});
|
|
117
|
+
await Promise.resolve();
|
|
118
|
+
expect(onCancel).toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
it("N key (uppercase) triggers cancel action (case-insensitive)", async () => {
|
|
121
|
+
const { onCancel } = renderDialog();
|
|
122
|
+
expect(inputState.handler).not.toBeNull();
|
|
123
|
+
inputState.handler("N", {});
|
|
124
|
+
await Promise.resolve();
|
|
125
|
+
expect(onCancel).toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
it("Escape triggers quit", async () => {
|
|
128
|
+
const { onQuit } = renderDialog();
|
|
129
|
+
expect(inputState.handler).not.toBeNull();
|
|
130
|
+
inputState.handler("", { escape: true });
|
|
131
|
+
await Promise.resolve();
|
|
132
|
+
expect(onQuit).toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cleanup, render } from "ink-testing-library";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { ThemeContext } from "../../ThemeContext.js";
|
|
5
|
+
import { darkTheme } from "../../themes/dark.js";
|
|
6
|
+
import { KEY_LABELS } from "../../constants/keybindings.js";
|
|
7
|
+
import { Footer } from "../Footer.js";
|
|
8
|
+
const mockThemeValue = {
|
|
9
|
+
theme: darkTheme,
|
|
10
|
+
themeName: "AUQ dark",
|
|
11
|
+
cycleTheme: () => { },
|
|
12
|
+
};
|
|
13
|
+
function renderWithTheme(ui) {
|
|
14
|
+
return render(React.createElement(ThemeContext.Provider, { value: mockThemeValue }, ui));
|
|
15
|
+
}
|
|
16
|
+
function getOutput(frame) {
|
|
17
|
+
return (frame ?? "").replace(/\x1b\[[0-9;]*m/g, "").replace(/\r/g, "");
|
|
18
|
+
}
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
cleanup();
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
});
|
|
23
|
+
describe("Footer keybinding labels", () => {
|
|
24
|
+
it("option context (single-select) shows correct keybindings", () => {
|
|
25
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false }));
|
|
26
|
+
const output = getOutput(instance.lastFrame());
|
|
27
|
+
expect(output).toContain(KEY_LABELS.SELECT); // "Space"
|
|
28
|
+
expect(output).toContain(KEY_LABELS.SELECT_NEXT); // "Enter"
|
|
29
|
+
expect(output).toContain(KEY_LABELS.REJECT); // "Esc"
|
|
30
|
+
expect(output).toContain(KEY_LABELS.NAVIGATE_OPTIONS); // "↑↓"
|
|
31
|
+
expect(output).toContain(KEY_LABELS.NAVIGATE_QUESTIONS); // "←→"
|
|
32
|
+
});
|
|
33
|
+
it("option context (multi-select) shows Toggle label", () => {
|
|
34
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: true }));
|
|
35
|
+
const output = getOutput(instance.lastFrame());
|
|
36
|
+
expect(output).toContain("Toggle");
|
|
37
|
+
expect(output).toContain(KEY_LABELS.SELECT); // "Space"
|
|
38
|
+
expect(output).toContain(KEY_LABELS.NEXT); // "Enter"
|
|
39
|
+
});
|
|
40
|
+
it("custom-input context shows correct keybindings", () => {
|
|
41
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "custom-input", multiSelect: false }));
|
|
42
|
+
const output = getOutput(instance.lastFrame());
|
|
43
|
+
expect(output).toContain(KEY_LABELS.NAVIGATE_OPTIONS); // "↑↓"
|
|
44
|
+
expect(output).toContain(KEY_LABELS.CURSOR); // "←→"
|
|
45
|
+
expect(output).toContain(KEY_LABELS.NAVIGATE_QUESTIONS_TAB); // "Tab/S+Tab"
|
|
46
|
+
expect(output).toContain(KEY_LABELS.NEWLINE); // "Enter"
|
|
47
|
+
expect(output).toContain(KEY_LABELS.REJECT); // "Esc"
|
|
48
|
+
});
|
|
49
|
+
it("elaborate-input context shows correct keybindings", () => {
|
|
50
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "elaborate-input", multiSelect: false }));
|
|
51
|
+
const output = getOutput(instance.lastFrame());
|
|
52
|
+
expect(output).toContain(KEY_LABELS.NAVIGATE_OPTIONS); // "↑↓"
|
|
53
|
+
expect(output).toContain(KEY_LABELS.CURSOR); // "←→"
|
|
54
|
+
expect(output).toContain("Enter/Tab");
|
|
55
|
+
expect(output).toContain(KEY_LABELS.REJECT); // "Esc"
|
|
56
|
+
});
|
|
57
|
+
it("review screen shows Submit and Back labels", () => {
|
|
58
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false, isReviewScreen: true }));
|
|
59
|
+
const output = getOutput(instance.lastFrame());
|
|
60
|
+
expect(output).toContain(KEY_LABELS.SUBMIT); // "Enter"
|
|
61
|
+
expect(output).toContain(KEY_LABELS.BACK); // "n"
|
|
62
|
+
expect(output).toContain("Submit");
|
|
63
|
+
expect(output).toContain("Back");
|
|
64
|
+
});
|
|
65
|
+
it("session switch shows ]/[ (not Ctrl+]/[)", () => {
|
|
66
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false, showSessionSwitching: true }));
|
|
67
|
+
const output = getOutput(instance.lastFrame());
|
|
68
|
+
// Should show "]/ [" session label
|
|
69
|
+
expect(output).toContain(KEY_LABELS.SESSION_SWITCH); // "]/["
|
|
70
|
+
// Must NOT contain Ctrl+] or Ctrl+[ anywhere
|
|
71
|
+
expect(output).not.toContain("Ctrl+]");
|
|
72
|
+
expect(output).not.toContain("Ctrl+[");
|
|
73
|
+
});
|
|
74
|
+
it("recommended key shown when hasRecommendedOptions is true", () => {
|
|
75
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false, hasRecommendedOptions: true }));
|
|
76
|
+
const output = getOutput(instance.lastFrame());
|
|
77
|
+
expect(output).toContain(KEY_LABELS.RECOMMEND); // "R"
|
|
78
|
+
expect(output).toContain("Recommended");
|
|
79
|
+
});
|
|
80
|
+
it("recommended key NOT shown when hasRecommendedOptions is false", () => {
|
|
81
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false, hasRecommendedOptions: false }));
|
|
82
|
+
const output = getOutput(instance.lastFrame());
|
|
83
|
+
expect(output).not.toContain("Recommended");
|
|
84
|
+
});
|
|
85
|
+
it("quick submit shown when hasAnyRecommendedInSession is true", () => {
|
|
86
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false, hasAnyRecommendedInSession: true }));
|
|
87
|
+
const output = getOutput(instance.lastFrame());
|
|
88
|
+
expect(output).toContain(KEY_LABELS.QUICK_SUBMIT); // "Ctrl+R"
|
|
89
|
+
expect(output).toContain("Quick Submit");
|
|
90
|
+
});
|
|
91
|
+
it("quick submit NOT shown when hasAnyRecommendedInSession is false", () => {
|
|
92
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false, hasAnyRecommendedInSession: false }));
|
|
93
|
+
const output = getOutput(instance.lastFrame());
|
|
94
|
+
expect(output).not.toContain("Quick Submit");
|
|
95
|
+
});
|
|
96
|
+
it("session switching shows 1-9 jump and Ctrl+S list labels", () => {
|
|
97
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false, showSessionSwitching: true }));
|
|
98
|
+
const output = getOutput(instance.lastFrame());
|
|
99
|
+
expect(output).toContain("1-9");
|
|
100
|
+
expect(output).toContain("Jump");
|
|
101
|
+
expect(output).toContain(KEY_LABELS.SESSION_LIST); // "Ctrl+S"
|
|
102
|
+
expect(output).toContain("List");
|
|
103
|
+
});
|
|
104
|
+
it("theme toggle is always shown in option context", () => {
|
|
105
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false }));
|
|
106
|
+
const output = getOutput(instance.lastFrame());
|
|
107
|
+
expect(output).toContain(KEY_LABELS.THEME); // "Ctrl+T"
|
|
108
|
+
expect(output).toContain("Theme");
|
|
109
|
+
});
|
|
110
|
+
it("review screen shows only submit and back, not option keybindings", () => {
|
|
111
|
+
const instance = renderWithTheme(React.createElement(Footer, { focusContext: "option", multiSelect: false, isReviewScreen: true }));
|
|
112
|
+
const output = getOutput(instance.lastFrame());
|
|
113
|
+
// Review screen should only show Submit and Back
|
|
114
|
+
expect(output).toContain("Submit");
|
|
115
|
+
expect(output).toContain("Back");
|
|
116
|
+
// Should NOT contain option-context keybindings
|
|
117
|
+
expect(output).not.toContain("Toggle");
|
|
118
|
+
expect(output).not.toContain("Theme");
|
|
119
|
+
expect(output).not.toContain("Questions");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cleanup, render } from "ink-testing-library";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
const inputState = vi.hoisted(() => ({
|
|
5
|
+
handler: null,
|
|
6
|
+
}));
|
|
7
|
+
vi.mock("ink", async () => {
|
|
8
|
+
const actual = await vi.importActual("ink");
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
useInput: (handler, options) => {
|
|
12
|
+
// ReviewScreen calls useInput without options, so always capture
|
|
13
|
+
if (!options || options.isActive !== false) {
|
|
14
|
+
inputState.handler = handler;
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
import { ThemeContext } from "../../ThemeContext.js";
|
|
20
|
+
import { darkTheme } from "../../themes/dark.js";
|
|
21
|
+
import { ReviewScreen } from "../ReviewScreen.js";
|
|
22
|
+
const mockThemeValue = {
|
|
23
|
+
theme: darkTheme,
|
|
24
|
+
themeName: "AUQ dark",
|
|
25
|
+
cycleTheme: () => { },
|
|
26
|
+
};
|
|
27
|
+
function renderWithTheme(ui) {
|
|
28
|
+
return render(React.createElement(ThemeContext.Provider, { value: mockThemeValue }, ui));
|
|
29
|
+
}
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
cleanup();
|
|
32
|
+
inputState.handler = null;
|
|
33
|
+
vi.restoreAllMocks();
|
|
34
|
+
});
|
|
35
|
+
describe("ReviewScreen keyboard handling", () => {
|
|
36
|
+
const sampleQuestions = [
|
|
37
|
+
{
|
|
38
|
+
title: "Q1",
|
|
39
|
+
prompt: "Choose an option",
|
|
40
|
+
options: [{ label: "Option A" }, { label: "Option B" }],
|
|
41
|
+
multiSelect: false,
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
const sampleAnswers = new Map([
|
|
45
|
+
[0, { selectedOption: "Option A" }],
|
|
46
|
+
]);
|
|
47
|
+
function renderReview(overrides = {}) {
|
|
48
|
+
const props = {
|
|
49
|
+
questions: sampleQuestions,
|
|
50
|
+
answers: sampleAnswers,
|
|
51
|
+
elapsedLabel: "5s",
|
|
52
|
+
sessionId: "test-session-1",
|
|
53
|
+
onConfirm: vi.fn(),
|
|
54
|
+
onGoBack: vi.fn(),
|
|
55
|
+
isSubmitting: false,
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
const instance = renderWithTheme(React.createElement(ReviewScreen, { ...props }));
|
|
59
|
+
return { instance, ...props };
|
|
60
|
+
}
|
|
61
|
+
it("Enter submits answers", async () => {
|
|
62
|
+
const { onConfirm } = renderReview();
|
|
63
|
+
expect(inputState.handler).not.toBeNull();
|
|
64
|
+
inputState.handler("", { return: true });
|
|
65
|
+
await Promise.resolve();
|
|
66
|
+
expect(onConfirm).toHaveBeenCalledTimes(1);
|
|
67
|
+
});
|
|
68
|
+
it("n key goes back", async () => {
|
|
69
|
+
const { onGoBack } = renderReview();
|
|
70
|
+
expect(inputState.handler).not.toBeNull();
|
|
71
|
+
inputState.handler("n", {});
|
|
72
|
+
await Promise.resolve();
|
|
73
|
+
expect(onGoBack).toHaveBeenCalledTimes(1);
|
|
74
|
+
});
|
|
75
|
+
it("N key (uppercase) goes back (case-insensitive)", async () => {
|
|
76
|
+
const { onGoBack } = renderReview();
|
|
77
|
+
expect(inputState.handler).not.toBeNull();
|
|
78
|
+
inputState.handler("N", {});
|
|
79
|
+
await Promise.resolve();
|
|
80
|
+
expect(onGoBack).toHaveBeenCalledTimes(1);
|
|
81
|
+
});
|
|
82
|
+
it("Enter does not fire while submitting", async () => {
|
|
83
|
+
const { onConfirm } = renderReview({ isSubmitting: true });
|
|
84
|
+
expect(inputState.handler).not.toBeNull();
|
|
85
|
+
inputState.handler("", { return: true });
|
|
86
|
+
await Promise.resolve();
|
|
87
|
+
expect(onConfirm).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -15,7 +15,7 @@ function renderWithTheme(ui) {
|
|
|
15
15
|
function getOutput(frame) {
|
|
16
16
|
return (frame ?? "").replace(/\x1b\[[0-9;]*m/g, "").replace(/\r/g, "");
|
|
17
17
|
}
|
|
18
|
-
function createSession(id) {
|
|
18
|
+
function createSession(id, overrides) {
|
|
19
19
|
return {
|
|
20
20
|
sessionId: `test-id-${id}`,
|
|
21
21
|
sessionRequest: {
|
|
@@ -33,6 +33,7 @@ function createSession(id) {
|
|
|
33
33
|
],
|
|
34
34
|
},
|
|
35
35
|
timestamp: new Date("2026-01-01T00:00:00.000Z"),
|
|
36
|
+
...overrides,
|
|
36
37
|
};
|
|
37
38
|
}
|
|
38
39
|
afterEach(() => {
|
|
@@ -89,4 +90,162 @@ describe("SessionDots", () => {
|
|
|
89
90
|
expect(output).toContain("3");
|
|
90
91
|
expect(output).toContain("4");
|
|
91
92
|
});
|
|
93
|
+
describe("abandoned sessions", () => {
|
|
94
|
+
it("renders abandoned session with ✕ symbol when inactive", () => {
|
|
95
|
+
const sessions = [
|
|
96
|
+
createSession(1),
|
|
97
|
+
createSession(2, { isAbandoned: true }),
|
|
98
|
+
createSession(3),
|
|
99
|
+
];
|
|
100
|
+
const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 0, sessionUIStates: {} }));
|
|
101
|
+
const output = getOutput(instance.lastFrame());
|
|
102
|
+
// Abandoned inactive session uses ✕ instead of ○
|
|
103
|
+
expect(output).toContain("✕");
|
|
104
|
+
// Active session still uses ●
|
|
105
|
+
expect(output).toContain("●");
|
|
106
|
+
// Non-abandoned inactive session still uses ○
|
|
107
|
+
expect(output).toContain("○");
|
|
108
|
+
});
|
|
109
|
+
it("renders abandoned session with different ANSI styling than normal", () => {
|
|
110
|
+
// Render with abandoned session
|
|
111
|
+
const abandonedSessions = [
|
|
112
|
+
createSession(1),
|
|
113
|
+
createSession(2, { isAbandoned: true }),
|
|
114
|
+
];
|
|
115
|
+
const abandoned = renderWithTheme(React.createElement(SessionDots, { sessions: abandonedSessions, activeIndex: 0, sessionUIStates: {} }));
|
|
116
|
+
const abandonedRaw = abandoned.lastFrame() ?? "";
|
|
117
|
+
// Render with normal session
|
|
118
|
+
const normalSessions = [createSession(1), createSession(2)];
|
|
119
|
+
const normal = renderWithTheme(React.createElement(SessionDots, { sessions: normalSessions, activeIndex: 0, sessionUIStates: {} }));
|
|
120
|
+
const normalRaw = normal.lastFrame() ?? "";
|
|
121
|
+
// Abandoned session should render differently from normal
|
|
122
|
+
// (different ANSI codes due to error color)
|
|
123
|
+
expect(abandonedRaw).not.toBe(normalRaw);
|
|
124
|
+
});
|
|
125
|
+
it('shows "(AI disconnected)" text when active session is abandoned', () => {
|
|
126
|
+
const sessions = [
|
|
127
|
+
createSession(1),
|
|
128
|
+
createSession(2, { isAbandoned: true }),
|
|
129
|
+
];
|
|
130
|
+
const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 1, sessionUIStates: {} }));
|
|
131
|
+
const output = getOutput(instance.lastFrame());
|
|
132
|
+
expect(output).toContain("(AI disconnected)");
|
|
133
|
+
});
|
|
134
|
+
it('does NOT show "(AI disconnected)" when abandoned session is inactive', () => {
|
|
135
|
+
const sessions = [
|
|
136
|
+
createSession(1),
|
|
137
|
+
createSession(2, { isAbandoned: true }),
|
|
138
|
+
];
|
|
139
|
+
const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 0, sessionUIStates: {} }));
|
|
140
|
+
const output = getOutput(instance.lastFrame());
|
|
141
|
+
expect(output).not.toContain("(AI disconnected)");
|
|
142
|
+
});
|
|
143
|
+
it("uses ● for active abandoned session (not ✕)", () => {
|
|
144
|
+
const sessions = [
|
|
145
|
+
createSession(1),
|
|
146
|
+
createSession(2, { isAbandoned: true }),
|
|
147
|
+
];
|
|
148
|
+
const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 1, sessionUIStates: {} }));
|
|
149
|
+
const output = getOutput(instance.lastFrame());
|
|
150
|
+
// Active abandoned session should still use ● (filled dot), not ✕
|
|
151
|
+
expect(output).toContain("●");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
describe("stale sessions", () => {
|
|
155
|
+
it("renders stale session with ○ symbol (unchanged from normal)", () => {
|
|
156
|
+
const sessions = [
|
|
157
|
+
createSession(1),
|
|
158
|
+
createSession(2, { isStale: true }),
|
|
159
|
+
createSession(3),
|
|
160
|
+
];
|
|
161
|
+
const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 0, sessionUIStates: {} }));
|
|
162
|
+
const output = getOutput(instance.lastFrame());
|
|
163
|
+
// Stale sessions keep ○ but with yellow color
|
|
164
|
+
// Count: 1 active ●, 2 inactive ○ (one stale, one normal)
|
|
165
|
+
expect((output.match(/○/g) ?? []).length).toBe(2);
|
|
166
|
+
});
|
|
167
|
+
it("applies stale color when session is stale (color differs from untouched)", () => {
|
|
168
|
+
// When a stale session is active, it gets the stale/warning color
|
|
169
|
+
// and shows a "(stale)" label — verifying the flag is correctly consumed.
|
|
170
|
+
// Since ink-testing-library may strip ANSI in some envs, we verify
|
|
171
|
+
// that stale active sessions show the label as a proxy for color.
|
|
172
|
+
const sessions = [
|
|
173
|
+
createSession(1),
|
|
174
|
+
createSession(2, { isStale: true }),
|
|
175
|
+
];
|
|
176
|
+
const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 1, sessionUIStates: {} }));
|
|
177
|
+
const output = getOutput(instance.lastFrame());
|
|
178
|
+
// Active stale session should have the filled dot and stale label
|
|
179
|
+
expect(output).toContain("●");
|
|
180
|
+
expect(output).toContain("(stale)");
|
|
181
|
+
// Stale sessions don't use ✕ (that's only for abandoned)
|
|
182
|
+
expect(output).not.toContain("✕");
|
|
183
|
+
});
|
|
184
|
+
it('shows "(stale)" text when active session is stale', () => {
|
|
185
|
+
const sessions = [
|
|
186
|
+
createSession(1),
|
|
187
|
+
createSession(2, { isStale: true }),
|
|
188
|
+
];
|
|
189
|
+
const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 1, sessionUIStates: {} }));
|
|
190
|
+
const output = getOutput(instance.lastFrame());
|
|
191
|
+
expect(output).toContain("(stale)");
|
|
192
|
+
});
|
|
193
|
+
it('does NOT show "(stale)" when stale session is inactive', () => {
|
|
194
|
+
const sessions = [
|
|
195
|
+
createSession(1),
|
|
196
|
+
createSession(2, { isStale: true }),
|
|
197
|
+
];
|
|
198
|
+
const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 0, sessionUIStates: {} }));
|
|
199
|
+
const output = getOutput(instance.lastFrame());
|
|
200
|
+
expect(output).not.toContain("(stale)");
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
describe("mixed states", () => {
|
|
204
|
+
it("renders multiple sessions with mixed states correctly", () => {
|
|
205
|
+
const sessions = [
|
|
206
|
+
createSession(1), // normal (active)
|
|
207
|
+
createSession(2, { isAbandoned: true }), // abandoned
|
|
208
|
+
createSession(3, { isStale: true }), // stale
|
|
209
|
+
createSession(4), // normal
|
|
210
|
+
];
|
|
211
|
+
const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 0, sessionUIStates: {} }));
|
|
212
|
+
const output = getOutput(instance.lastFrame());
|
|
213
|
+
// Active session shows ●
|
|
214
|
+
expect(output).toContain("●");
|
|
215
|
+
// Abandoned inactive shows ✕
|
|
216
|
+
expect(output).toContain("✕");
|
|
217
|
+
// Normal and stale inactive show ○
|
|
218
|
+
expect((output.match(/○/g) ?? []).length).toBe(2);
|
|
219
|
+
// All 4 session numbers rendered
|
|
220
|
+
expect(output).toContain("1");
|
|
221
|
+
expect(output).toContain("2");
|
|
222
|
+
expect(output).toContain("3");
|
|
223
|
+
expect(output).toContain("4");
|
|
224
|
+
});
|
|
225
|
+
it("abandoned takes priority over stale", () => {
|
|
226
|
+
const sessions = [
|
|
227
|
+
createSession(1),
|
|
228
|
+
createSession(2, { isAbandoned: true, isStale: true }),
|
|
229
|
+
];
|
|
230
|
+
const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 1, sessionUIStates: {} }));
|
|
231
|
+
const output = getOutput(instance.lastFrame());
|
|
232
|
+
// When both abandoned and stale, show abandoned status
|
|
233
|
+
expect(output).toContain("(AI disconnected)");
|
|
234
|
+
expect(output).not.toContain("(stale)");
|
|
235
|
+
});
|
|
236
|
+
it("normal sessions remain unchanged (regression)", () => {
|
|
237
|
+
const sessions = [createSession(1), createSession(2), createSession(3)];
|
|
238
|
+
const instance = renderWithTheme(React.createElement(SessionDots, { sessions: sessions, activeIndex: 1, sessionUIStates: {} }));
|
|
239
|
+
const output = getOutput(instance.lastFrame());
|
|
240
|
+
// No stale/abandoned indicators for normal sessions
|
|
241
|
+
expect(output).not.toContain("✕");
|
|
242
|
+
expect(output).not.toContain("(AI disconnected)");
|
|
243
|
+
expect(output).not.toContain("(stale)");
|
|
244
|
+
// Normal rendering still works
|
|
245
|
+
expect(output).toContain("●");
|
|
246
|
+
expect(output).toContain("○");
|
|
247
|
+
expect((output.match(/●/g) ?? []).length).toBe(1);
|
|
248
|
+
expect((output.match(/○/g) ?? []).length).toBe(2);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
92
251
|
});
|
|
@@ -29,7 +29,7 @@ function renderWithTheme(ui) {
|
|
|
29
29
|
function getOutput(frame) {
|
|
30
30
|
return (frame ?? "").replace(/\x1b\[[0-9;]*m/g, "").replace(/\r/g, "");
|
|
31
31
|
}
|
|
32
|
-
function createSession(id) {
|
|
32
|
+
function createSession(id, overrides) {
|
|
33
33
|
return {
|
|
34
34
|
sessionId: `picker-id-${id}`,
|
|
35
35
|
sessionRequest: {
|
|
@@ -47,6 +47,7 @@ function createSession(id) {
|
|
|
47
47
|
],
|
|
48
48
|
},
|
|
49
49
|
timestamp: new Date("2026-01-01T00:00:00.000Z"),
|
|
50
|
+
...overrides,
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
53
|
afterEach(() => {
|
|
@@ -122,4 +123,45 @@ describe("SessionPicker", () => {
|
|
|
122
123
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
123
124
|
expect(onSelectIndex).not.toHaveBeenCalled();
|
|
124
125
|
});
|
|
126
|
+
describe("stale/abandoned session indicators", () => {
|
|
127
|
+
it("shows ⚠ icon for stale sessions", () => {
|
|
128
|
+
const sessions = [createSession(1), createSession(2, { isStale: true })];
|
|
129
|
+
const instance = renderWithTheme(React.createElement(SessionPicker, { isOpen: true, sessions: sessions, activeIndex: 0, sessionUIStates: {}, onSelectIndex: () => { }, onClose: () => { } }));
|
|
130
|
+
const output = getOutput(instance.lastFrame());
|
|
131
|
+
expect(output).toContain("⚠");
|
|
132
|
+
expect(output).toContain("Title 2");
|
|
133
|
+
});
|
|
134
|
+
it("shows 'may be orphaned' subtitle for stale sessions", () => {
|
|
135
|
+
const sessions = [createSession(1, { isStale: true })];
|
|
136
|
+
const instance = renderWithTheme(React.createElement(SessionPicker, { isOpen: true, sessions: sessions, activeIndex: 0, sessionUIStates: {}, onSelectIndex: () => { }, onClose: () => { } }));
|
|
137
|
+
const output = getOutput(instance.lastFrame());
|
|
138
|
+
expect(output).toContain("may be orphaned");
|
|
139
|
+
});
|
|
140
|
+
it("shows 'session abandoned' subtitle for abandoned sessions", () => {
|
|
141
|
+
const sessions = [createSession(1, { isAbandoned: true })];
|
|
142
|
+
const instance = renderWithTheme(React.createElement(SessionPicker, { isOpen: true, sessions: sessions, activeIndex: 0, sessionUIStates: {}, onSelectIndex: () => { }, onClose: () => { } }));
|
|
143
|
+
const output = getOutput(instance.lastFrame());
|
|
144
|
+
expect(output).toContain("⚠");
|
|
145
|
+
expect(output).toContain("session abandoned");
|
|
146
|
+
});
|
|
147
|
+
it("stale sessions remain selectable via Enter", async () => {
|
|
148
|
+
const sessions = [createSession(1, { isStale: true }), createSession(2)];
|
|
149
|
+
const onSelectIndex = vi.fn();
|
|
150
|
+
const onClose = vi.fn();
|
|
151
|
+
renderWithTheme(React.createElement(SessionPicker, { isOpen: true, sessions: sessions, activeIndex: 0, sessionUIStates: {}, onSelectIndex: onSelectIndex, onClose: onClose }));
|
|
152
|
+
expect(inputState.handler).not.toBeNull();
|
|
153
|
+
inputState.handler("", { return: true });
|
|
154
|
+
await Promise.resolve();
|
|
155
|
+
expect(onSelectIndex).toHaveBeenCalledWith(0);
|
|
156
|
+
expect(onClose).toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
it("non-stale sessions render normally without ⚠ or subtitles", () => {
|
|
159
|
+
const sessions = [createSession(1), createSession(2)];
|
|
160
|
+
const instance = renderWithTheme(React.createElement(SessionPicker, { isOpen: true, sessions: sessions, activeIndex: 0, sessionUIStates: {}, onSelectIndex: () => { }, onClose: () => { } }));
|
|
161
|
+
const output = getOutput(instance.lastFrame());
|
|
162
|
+
expect(output).not.toContain("⚠");
|
|
163
|
+
expect(output).not.toContain("may be orphaned");
|
|
164
|
+
expect(output).not.toContain("session abandoned");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
125
167
|
});
|