auq-mcp-server 2.3.0 → 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 +47 -93
- package/dist/bin/tui-app.js +69 -6
- 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 +7 -0
- package/dist/src/i18n/locales/ko.js +7 -0
- 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/__tests__/session-watcher.test.js +109 -0
- package/dist/src/tui/components/SessionDots.js +33 -4
- package/dist/src/tui/components/SessionPicker.js +25 -17
- package/dist/src/tui/components/Spinner.js +19 -0
- package/dist/src/tui/components/StepperView.js +68 -5
- 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.state.test.js +1 -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
|
@@ -590,4 +590,69 @@ describe("SessionManager", () => {
|
|
|
590
590
|
expect(result2.formattedResponse).toContain("Option 2");
|
|
591
591
|
});
|
|
592
592
|
});
|
|
593
|
+
describe("abandoned status support", () => {
|
|
594
|
+
const sampleQuestions = [
|
|
595
|
+
{ options: [{ label: "Opt" }], prompt: "Test", title: "Test" },
|
|
596
|
+
];
|
|
597
|
+
it("should transition a session to abandoned status via updateSessionStatus", async () => {
|
|
598
|
+
const sessionId = await sessionManager.createSession(sampleQuestions);
|
|
599
|
+
await sessionManager.updateSessionStatus(sessionId, "abandoned");
|
|
600
|
+
const status = await sessionManager.getSessionStatus(sessionId);
|
|
601
|
+
expect(status?.status).toBe("abandoned");
|
|
602
|
+
});
|
|
603
|
+
it("should return true from isAbandoned() for abandoned session", async () => {
|
|
604
|
+
const sessionId = await sessionManager.createSession(sampleQuestions);
|
|
605
|
+
await sessionManager.updateSessionStatus(sessionId, "abandoned");
|
|
606
|
+
const result = await sessionManager.isAbandoned(sessionId);
|
|
607
|
+
expect(result).toBe(true);
|
|
608
|
+
});
|
|
609
|
+
it("should return false from isAbandoned() for pending session", async () => {
|
|
610
|
+
const sessionId = await sessionManager.createSession(sampleQuestions);
|
|
611
|
+
const result = await sessionManager.isAbandoned(sessionId);
|
|
612
|
+
expect(result).toBe(false);
|
|
613
|
+
});
|
|
614
|
+
it("should return false from isAbandoned() for non-existent session", async () => {
|
|
615
|
+
const result = await sessionManager.isAbandoned("non-existent-session-id");
|
|
616
|
+
expect(result).toBe(false);
|
|
617
|
+
});
|
|
618
|
+
it("should exclude abandoned sessions from getPendingSessions() by default", async () => {
|
|
619
|
+
const pendingId = await sessionManager.createSession(sampleQuestions);
|
|
620
|
+
const abandonedId = await sessionManager.createSession(sampleQuestions);
|
|
621
|
+
await sessionManager.updateSessionStatus(abandonedId, "abandoned");
|
|
622
|
+
const pending = await sessionManager.getPendingSessions();
|
|
623
|
+
expect(pending).toContain(pendingId);
|
|
624
|
+
expect(pending).not.toContain(abandonedId);
|
|
625
|
+
});
|
|
626
|
+
it("should include abandoned sessions in getPendingSessions() when includeAbandoned is true", async () => {
|
|
627
|
+
const pendingId = await sessionManager.createSession(sampleQuestions);
|
|
628
|
+
const abandonedId = await sessionManager.createSession(sampleQuestions);
|
|
629
|
+
await sessionManager.updateSessionStatus(abandonedId, "abandoned");
|
|
630
|
+
const pending = await sessionManager.getPendingSessions({ includeAbandoned: true });
|
|
631
|
+
expect(pending).toContain(pendingId);
|
|
632
|
+
expect(pending).toContain(abandonedId);
|
|
633
|
+
});
|
|
634
|
+
it("should exclude completed sessions from getPendingSessions() even with includeAbandoned", async () => {
|
|
635
|
+
const pendingId = await sessionManager.createSession(sampleQuestions);
|
|
636
|
+
const completedId = await sessionManager.createSession(sampleQuestions);
|
|
637
|
+
const abandonedId = await sessionManager.createSession(sampleQuestions);
|
|
638
|
+
await sessionManager.updateSessionStatus(completedId, "completed");
|
|
639
|
+
await sessionManager.updateSessionStatus(abandonedId, "abandoned");
|
|
640
|
+
const pending = await sessionManager.getPendingSessions({ includeAbandoned: true });
|
|
641
|
+
expect(pending).toContain(pendingId);
|
|
642
|
+
expect(pending).toContain(abandonedId);
|
|
643
|
+
expect(pending).not.toContain(completedId);
|
|
644
|
+
});
|
|
645
|
+
it("should include in-progress sessions in getPendingSessions()", async () => {
|
|
646
|
+
const inProgressId = await sessionManager.createSession(sampleQuestions);
|
|
647
|
+
await sessionManager.updateSessionStatus(inProgressId, "in-progress");
|
|
648
|
+
const pending = await sessionManager.getPendingSessions();
|
|
649
|
+
expect(pending).toContain(inProgressId);
|
|
650
|
+
});
|
|
651
|
+
it("should return empty array from getPendingSessions() when no pending sessions exist", async () => {
|
|
652
|
+
const completedId = await sessionManager.createSession(sampleQuestions);
|
|
653
|
+
await sessionManager.updateSessionStatus(completedId, "completed");
|
|
654
|
+
const pending = await sessionManager.getPendingSessions();
|
|
655
|
+
expect(pending).toEqual([]);
|
|
656
|
+
});
|
|
657
|
+
});
|
|
593
658
|
});
|
|
@@ -270,6 +270,115 @@ describe("TUI Session Watcher", () => {
|
|
|
270
270
|
watcher.stop();
|
|
271
271
|
});
|
|
272
272
|
});
|
|
273
|
+
describe("getPendingSessionsWithStatus", () => {
|
|
274
|
+
beforeEach(async () => {
|
|
275
|
+
// Create test sessions with various statuses
|
|
276
|
+
const sessions = [
|
|
277
|
+
{ id: "session-1", status: "pending", completed: false },
|
|
278
|
+
{ id: "session-2", status: "completed", completed: true },
|
|
279
|
+
{ id: "session-3", status: "pending", completed: false },
|
|
280
|
+
];
|
|
281
|
+
for (const session of sessions) {
|
|
282
|
+
const dir = join(sessionDir, session.id);
|
|
283
|
+
await fs.mkdir(dir, { recursive: true });
|
|
284
|
+
const requestFile = join(dir, SESSION_FILES.REQUEST);
|
|
285
|
+
await fs.writeFile(requestFile, JSON.stringify({
|
|
286
|
+
...mockSessionRequest,
|
|
287
|
+
sessionId: session.id,
|
|
288
|
+
}));
|
|
289
|
+
const statusFile = join(dir, SESSION_FILES.STATUS);
|
|
290
|
+
await fs.writeFile(statusFile, JSON.stringify({
|
|
291
|
+
createdAt: new Date().toISOString(),
|
|
292
|
+
lastModified: new Date().toISOString(),
|
|
293
|
+
sessionId: session.id,
|
|
294
|
+
status: session.status,
|
|
295
|
+
totalQuestions: 1,
|
|
296
|
+
}));
|
|
297
|
+
if (session.completed) {
|
|
298
|
+
const answersFile = join(dir, SESSION_FILES.ANSWERS);
|
|
299
|
+
await fs.writeFile(answersFile, JSON.stringify({
|
|
300
|
+
answers: [
|
|
301
|
+
{
|
|
302
|
+
questionIndex: 0,
|
|
303
|
+
selectedOption: "JavaScript",
|
|
304
|
+
timestamp: new Date().toISOString(),
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
sessionId: session.id,
|
|
308
|
+
timestamp: new Date().toISOString(),
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
it("should return pending and in-progress sessions with metadata", async () => {
|
|
314
|
+
const watcher = new EnhancedTUISessionWatcher({ sessionDir });
|
|
315
|
+
const sessions = await watcher.getPendingSessionsWithStatus();
|
|
316
|
+
expect(sessions).toHaveLength(2);
|
|
317
|
+
expect(sessions[0].sessionId).toBe("session-1");
|
|
318
|
+
expect(sessions[0].status).toBe("pending");
|
|
319
|
+
expect(sessions[0].createdAt).toBeDefined();
|
|
320
|
+
expect(sessions[1].sessionId).toBe("session-3");
|
|
321
|
+
expect(sessions[1].status).toBe("pending");
|
|
322
|
+
watcher.stop();
|
|
323
|
+
});
|
|
324
|
+
it("should include abandoned sessions", async () => {
|
|
325
|
+
// Create an abandoned session
|
|
326
|
+
const abandonedDir = join(sessionDir, "session-4-abandoned");
|
|
327
|
+
await fs.mkdir(abandonedDir, { recursive: true });
|
|
328
|
+
const requestFile = join(abandonedDir, SESSION_FILES.REQUEST);
|
|
329
|
+
const statusFile = join(abandonedDir, SESSION_FILES.STATUS);
|
|
330
|
+
await Promise.all([
|
|
331
|
+
fs.writeFile(requestFile, JSON.stringify({
|
|
332
|
+
...mockSessionRequest,
|
|
333
|
+
sessionId: "session-4-abandoned",
|
|
334
|
+
})),
|
|
335
|
+
fs.writeFile(statusFile, JSON.stringify({
|
|
336
|
+
createdAt: new Date().toISOString(),
|
|
337
|
+
lastModified: new Date().toISOString(),
|
|
338
|
+
sessionId: "session-4-abandoned",
|
|
339
|
+
status: "abandoned",
|
|
340
|
+
totalQuestions: 1,
|
|
341
|
+
})),
|
|
342
|
+
]);
|
|
343
|
+
const watcher = new EnhancedTUISessionWatcher({ sessionDir });
|
|
344
|
+
const sessions = await watcher.getPendingSessionsWithStatus();
|
|
345
|
+
// Should have session-1 (pending), session-3 (pending), session-4-abandoned (abandoned)
|
|
346
|
+
expect(sessions).toHaveLength(3);
|
|
347
|
+
const abandoned = sessions.find((s) => s.sessionId === "session-4-abandoned");
|
|
348
|
+
expect(abandoned).toBeDefined();
|
|
349
|
+
expect(abandoned.status).toBe("abandoned");
|
|
350
|
+
watcher.stop();
|
|
351
|
+
});
|
|
352
|
+
it("should exclude completed, rejected, and timed_out sessions", async () => {
|
|
353
|
+
const watcher = new EnhancedTUISessionWatcher({ sessionDir });
|
|
354
|
+
const sessions = await watcher.getPendingSessionsWithStatus();
|
|
355
|
+
// session-2 is completed and has answers — should be excluded
|
|
356
|
+
const completed = sessions.find((s) => s.sessionId === "session-2");
|
|
357
|
+
expect(completed).toBeUndefined();
|
|
358
|
+
watcher.stop();
|
|
359
|
+
});
|
|
360
|
+
it("should return sorted results by sessionId", async () => {
|
|
361
|
+
const watcher = new EnhancedTUISessionWatcher({ sessionDir });
|
|
362
|
+
const sessions = await watcher.getPendingSessionsWithStatus();
|
|
363
|
+
const ids = sessions.map((s) => s.sessionId);
|
|
364
|
+
const sorted = [...ids].sort();
|
|
365
|
+
expect(ids).toEqual(sorted);
|
|
366
|
+
watcher.stop();
|
|
367
|
+
});
|
|
368
|
+
it("should handle directory access errors gracefully", async () => {
|
|
369
|
+
const watcher = new EnhancedTUISessionWatcher({
|
|
370
|
+
sessionDir: "/invalid/directory/path",
|
|
371
|
+
});
|
|
372
|
+
const consoleSpy = vi
|
|
373
|
+
.spyOn(console, "warn")
|
|
374
|
+
.mockImplementation(() => { });
|
|
375
|
+
const sessions = await watcher.getPendingSessionsWithStatus();
|
|
376
|
+
expect(sessions).toEqual([]);
|
|
377
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to scan for pending sessions with status"), expect.any(Error));
|
|
378
|
+
consoleSpy.mockRestore();
|
|
379
|
+
watcher.stop();
|
|
380
|
+
});
|
|
381
|
+
});
|
|
273
382
|
});
|
|
274
383
|
describe("Utility Functions", () => {
|
|
275
384
|
describe("createTUIWatcher", () => {
|
|
@@ -23,9 +23,11 @@ function hasAnswers(answers) {
|
|
|
23
23
|
* SessionDots — a compact row of numbered dots rendered below the footer.
|
|
24
24
|
*
|
|
25
25
|
* Visual language:
|
|
26
|
-
* ● 1 ○ 2 ○
|
|
26
|
+
* ● 1 ○ 2 ✕ 3 ○ 4
|
|
27
27
|
*
|
|
28
28
|
* • Active session: filled ● + bold number in theme primary
|
|
29
|
+
* • Abandoned: red ✕ + "(AI disconnected)" when active
|
|
30
|
+
* • Stale: yellow ○ + "(stale)" when active
|
|
29
31
|
* • Has answers: green (theme.success)
|
|
30
32
|
* • Touched/no answers: yellow (theme.warning)
|
|
31
33
|
* • Untouched: dim (theme.textDim)
|
|
@@ -38,9 +40,22 @@ export const SessionDots = ({ sessions, activeIndex, sessionUIStates, }) => {
|
|
|
38
40
|
return (React.createElement(Box, { justifyContent: "center", paddingX: 1 }, sessions.map((session, idx) => {
|
|
39
41
|
const isActive = idx === activeIndex;
|
|
40
42
|
const uiState = sessionUIStates[session.sessionId];
|
|
43
|
+
const isStale = session.isStale ?? false;
|
|
44
|
+
const isAbandoned = session.isAbandoned ?? false;
|
|
41
45
|
// Determine the progress color for this session's dot
|
|
46
|
+
// Abandoned/stale take priority over normal state colors
|
|
42
47
|
let dotColor;
|
|
43
|
-
if (
|
|
48
|
+
if (isAbandoned) {
|
|
49
|
+
dotColor =
|
|
50
|
+
theme.components.sessionDots
|
|
51
|
+
.abandoned ?? theme.colors.error;
|
|
52
|
+
}
|
|
53
|
+
else if (isStale) {
|
|
54
|
+
dotColor =
|
|
55
|
+
theme.components.sessionDots
|
|
56
|
+
.stale ?? theme.colors.warning;
|
|
57
|
+
}
|
|
58
|
+
else if (isActive) {
|
|
44
59
|
dotColor = theme.components.sessionDots.active;
|
|
45
60
|
}
|
|
46
61
|
else if (uiState && hasAnswers(uiState.answers)) {
|
|
@@ -52,14 +67,28 @@ export const SessionDots = ({ sessions, activeIndex, sessionUIStates, }) => {
|
|
|
52
67
|
else {
|
|
53
68
|
dotColor = theme.components.sessionDots.untouched;
|
|
54
69
|
}
|
|
55
|
-
|
|
70
|
+
// Abandoned inactive sessions use ✕ to signal a problem
|
|
71
|
+
const dot = isAbandoned && !isActive
|
|
72
|
+
? "✕"
|
|
73
|
+
: isActive
|
|
74
|
+
? "●"
|
|
75
|
+
: "○";
|
|
56
76
|
const numberColor = isActive
|
|
57
77
|
? theme.components.sessionDots.activeNumber
|
|
58
78
|
: theme.components.sessionDots.number;
|
|
79
|
+
// Status label shown next to active abandoned/stale sessions
|
|
80
|
+
const statusLabel = isActive && isAbandoned
|
|
81
|
+
? "(AI disconnected)"
|
|
82
|
+
: isActive && isStale
|
|
83
|
+
? "(stale)"
|
|
84
|
+
: null;
|
|
59
85
|
return (React.createElement(Box, { key: session.sessionId, paddingRight: idx < sessions.length - 1 ? 1 : 0 },
|
|
60
86
|
React.createElement(Text, { color: dotColor, bold: isActive }, dot),
|
|
61
87
|
React.createElement(Text, { color: numberColor, bold: isActive },
|
|
62
88
|
" ",
|
|
63
|
-
idx + 1)
|
|
89
|
+
idx + 1),
|
|
90
|
+
statusLabel && (React.createElement(Text, { color: dotColor, dimColor: true },
|
|
91
|
+
" ",
|
|
92
|
+
statusLabel))));
|
|
64
93
|
})));
|
|
65
94
|
};
|
|
@@ -135,23 +135,31 @@ export const SessionPicker = ({ isOpen, sessions, activeIndex, sessionUIStates,
|
|
|
135
135
|
: answered > 0
|
|
136
136
|
? theme.components.sessionPicker.progress
|
|
137
137
|
: theme.components.sessionPicker.rowDim;
|
|
138
|
-
return (React.createElement(Box, { key: session.sessionId },
|
|
139
|
-
React.createElement(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
138
|
+
return (React.createElement(Box, { key: session.sessionId, flexDirection: "column" },
|
|
139
|
+
React.createElement(Box, null,
|
|
140
|
+
(session.isStale || session.isAbandoned) && (React.createElement(Text, { backgroundColor: rowBg, color: theme.components.sessionPicker.staleIcon },
|
|
141
|
+
"\u26A0",
|
|
142
|
+
" ")),
|
|
143
|
+
React.createElement(Text, { backgroundColor: rowBg, bold: isHighlighted, color: session.isStale || session.isAbandoned ? theme.components.sessionPicker.staleText : textColor },
|
|
144
|
+
isActive ? "►" : " ",
|
|
145
|
+
" ",
|
|
146
|
+
realIdx + 1,
|
|
147
|
+
". ",
|
|
148
|
+
title),
|
|
149
|
+
React.createElement(Text, { backgroundColor: rowBg, color: theme.components.sessionPicker.rowDim },
|
|
150
|
+
" — ",
|
|
151
|
+
dir),
|
|
152
|
+
React.createElement(Text, { backgroundColor: rowBg, color: progressColor },
|
|
153
|
+
" [",
|
|
154
|
+
answered,
|
|
155
|
+
"/",
|
|
156
|
+
total,
|
|
157
|
+
"]"),
|
|
158
|
+
React.createElement(Text, { backgroundColor: rowBg, color: session.isStale || session.isAbandoned ? theme.components.sessionPicker.staleAge : theme.components.sessionPicker.rowDim, dimColor: !(session.isStale || session.isAbandoned) }, age)),
|
|
159
|
+
session.isStale && !session.isAbandoned && (React.createElement(Box, { marginLeft: session.isStale ? 4 : 2 },
|
|
160
|
+
React.createElement(Text, { color: theme.components.sessionPicker.staleSubtitle, dimColor: true }, "may be orphaned"))),
|
|
161
|
+
session.isAbandoned && (React.createElement(Box, { marginLeft: 4 },
|
|
162
|
+
React.createElement(Text, { color: theme.components.sessionPicker.staleSubtitle, bold: true }, "session abandoned")))));
|
|
155
163
|
}),
|
|
156
164
|
needsScroll && scrollOffset + maxVisibleRows < sessions.length && (React.createElement(Box, { justifyContent: "center" },
|
|
157
165
|
React.createElement(Text, { color: theme.components.sessionPicker.rowDim }, "\u25BC more"))),
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Text } from "ink";
|
|
2
|
+
import React, { useEffect, useState } from "react";
|
|
3
|
+
import { useTheme } from "../ThemeContext.js";
|
|
4
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
5
|
+
/**
|
|
6
|
+
* Spinner displays an animated loading indicator
|
|
7
|
+
* Uses braille pattern characters for smooth animation
|
|
8
|
+
*/
|
|
9
|
+
export const Spinner = ({ color }) => {
|
|
10
|
+
const { theme } = useTheme();
|
|
11
|
+
const [frame, setFrame] = useState(0);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const timer = setInterval(() => {
|
|
14
|
+
setFrame((prev) => (prev + 1) % SPINNER_FRAMES.length);
|
|
15
|
+
}, 80);
|
|
16
|
+
return () => clearInterval(timer);
|
|
17
|
+
}, []);
|
|
18
|
+
return (React.createElement(Text, { color: color ?? theme.colors.primary }, SPINNER_FRAMES[frame]));
|
|
19
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Box, useApp, useInput, useStdout } from "ink";
|
|
1
|
+
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
2
2
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { t } from "../../i18n/index.js";
|
|
4
4
|
import { ResponseFormatter } from "../../session/ResponseFormatter.js";
|
|
@@ -15,7 +15,7 @@ import { ReviewScreen } from "./ReviewScreen.js";
|
|
|
15
15
|
* StepperView orchestrates the question-answering flow
|
|
16
16
|
* Manages state for current question, answers, and navigation
|
|
17
17
|
*/
|
|
18
|
-
export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initialState, onStateSnapshot, onFlowStateChange, sessionId, sessionRequest, }) => {
|
|
18
|
+
export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initialState, onStateSnapshot, onFlowStateChange, sessionId, sessionRequest, isAbandoned, onAbandonedCancel, }) => {
|
|
19
19
|
const { theme } = useTheme();
|
|
20
20
|
const config = useConfig();
|
|
21
21
|
const { exit } = useApp();
|
|
@@ -24,6 +24,9 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
|
|
|
24
24
|
const [showReview, setShowReview] = useState(false);
|
|
25
25
|
const [submitting, setSubmitting] = useState(false);
|
|
26
26
|
const [showRejectionConfirm, setShowRejectionConfirm] = useState(false);
|
|
27
|
+
const [showAbandonedConfirm, setShowAbandonedConfirm] = useState(false);
|
|
28
|
+
const [abandonedConfirmed, setAbandonedConfirmed] = useState(false);
|
|
29
|
+
const [abandonedFocusedIndex, setAbandonedFocusedIndex] = useState(0);
|
|
27
30
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
|
28
31
|
const [focusContext, setFocusContext] = useState("option");
|
|
29
32
|
const [focusedOptionIndex, setFocusedOptionIndex] = useState(0);
|
|
@@ -180,6 +183,16 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
|
|
|
180
183
|
const anyHasRecommended = sessionRequest.questions.some((question) => question.options.some((opt) => isRecommendedOption(opt.label)));
|
|
181
184
|
setHasAnyRecommendedInSession(anyHasRecommended);
|
|
182
185
|
}, [initialState, sessionId, sessionRequest.questions]);
|
|
186
|
+
// Show abandoned confirmation when entering an abandoned session
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (isAbandoned && !abandonedConfirmed) {
|
|
189
|
+
setShowAbandonedConfirm(true);
|
|
190
|
+
setAbandonedFocusedIndex(0);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
setShowAbandonedConfirm(false);
|
|
194
|
+
}
|
|
195
|
+
}, [sessionId, isAbandoned, abandonedConfirmed]);
|
|
183
196
|
useEffect(() => {
|
|
184
197
|
if (!onStateSnapshot) {
|
|
185
198
|
return;
|
|
@@ -207,8 +220,8 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
|
|
|
207
220
|
showReview,
|
|
208
221
|
]);
|
|
209
222
|
useEffect(() => {
|
|
210
|
-
onFlowStateChange?.({ showReview, showRejectionConfirm });
|
|
211
|
-
}, [onFlowStateChange, showRejectionConfirm, showReview]);
|
|
223
|
+
onFlowStateChange?.({ showReview, showRejectionConfirm, showAbandonedConfirm });
|
|
224
|
+
}, [onFlowStateChange, showRejectionConfirm, showReview, showAbandonedConfirm]);
|
|
212
225
|
// Update elapsed time since session creation
|
|
213
226
|
// IMPORTANT: Pause when content overflows terminal to prevent scroll-snapping
|
|
214
227
|
useEffect(() => {
|
|
@@ -343,10 +356,35 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
|
|
|
343
356
|
return newMarks;
|
|
344
357
|
});
|
|
345
358
|
};
|
|
359
|
+
// Keyboard handling for abandoned confirmation dialog
|
|
360
|
+
useInput((input, key) => {
|
|
361
|
+
if (!showAbandonedConfirm)
|
|
362
|
+
return;
|
|
363
|
+
if (key.upArrow) {
|
|
364
|
+
setAbandonedFocusedIndex((prev) => Math.max(0, prev - 1));
|
|
365
|
+
}
|
|
366
|
+
if (key.downArrow) {
|
|
367
|
+
setAbandonedFocusedIndex((prev) => Math.min(1, prev + 1));
|
|
368
|
+
}
|
|
369
|
+
if (key.return) {
|
|
370
|
+
if (abandonedFocusedIndex === 0) {
|
|
371
|
+
// "Answer anyway"
|
|
372
|
+
setAbandonedConfirmed(true);
|
|
373
|
+
setShowAbandonedConfirm(false);
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
// "Cancel"
|
|
377
|
+
onAbandonedCancel?.();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (key.escape) {
|
|
381
|
+
onAbandonedCancel?.();
|
|
382
|
+
}
|
|
383
|
+
});
|
|
346
384
|
// Global keyboard shortcuts and navigation
|
|
347
385
|
useInput((input, key) => {
|
|
348
386
|
// Don't handle navigation when showing review, submitting, or confirming rejection
|
|
349
|
-
if (showReview || submitting || showRejectionConfirm)
|
|
387
|
+
if (showReview || submitting || showRejectionConfirm || showAbandonedConfirm)
|
|
350
388
|
return;
|
|
351
389
|
// Derive text-input state from both focusContext and focusedOptionIndex
|
|
352
390
|
// focusContext may lag by one render cycle (set via useEffect in OptionsList)
|
|
@@ -481,6 +519,31 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
|
|
|
481
519
|
}
|
|
482
520
|
}
|
|
483
521
|
};
|
|
522
|
+
// Show abandoned session confirmation
|
|
523
|
+
if (showAbandonedConfirm) {
|
|
524
|
+
const abandonedOptions = [
|
|
525
|
+
{ label: t("abandoned.continue"), action: () => { setAbandonedConfirmed(true); setShowAbandonedConfirm(false); } },
|
|
526
|
+
{ label: t("abandoned.cancel"), action: () => { onAbandonedCancel?.(); } },
|
|
527
|
+
];
|
|
528
|
+
return (React.createElement(Box, { flexDirection: "column", padding: 1 },
|
|
529
|
+
React.createElement(Box, { borderColor: theme.borders.warning, borderStyle: "round", flexDirection: "column", padding: 1 },
|
|
530
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
531
|
+
React.createElement(Text, { bold: true, color: theme.colors.warning }, t("abandoned.title"))),
|
|
532
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
533
|
+
React.createElement(Text, null, t("abandoned.message"))),
|
|
534
|
+
abandonedOptions.map((option, index) => {
|
|
535
|
+
const isFocused = index === abandonedFocusedIndex;
|
|
536
|
+
const rowBg = isFocused
|
|
537
|
+
? theme.components.options.focusedBg
|
|
538
|
+
: undefined;
|
|
539
|
+
return (React.createElement(Box, { key: index, marginTop: index > 0 ? 0.5 : 0 },
|
|
540
|
+
React.createElement(Text, { backgroundColor: rowBg, bold: isFocused, color: isFocused ? theme.colors.focused : theme.colors.text },
|
|
541
|
+
isFocused ? "> " : " ",
|
|
542
|
+
option.label)));
|
|
543
|
+
}),
|
|
544
|
+
React.createElement(Box, { marginTop: 1 },
|
|
545
|
+
React.createElement(Text, { dimColor: true }, "↑↓ Navigate | Enter Select")))));
|
|
546
|
+
}
|
|
484
547
|
// Show rejection confirmation
|
|
485
548
|
if (showRejectionConfirm) {
|
|
486
549
|
return (React.createElement(Box, { flexDirection: "column", padding: 1 },
|
|
@@ -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
|
});
|