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
|
@@ -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
|
});
|
|
@@ -4,6 +4,7 @@ import { ThemeContext } from "./ThemeContext.js";
|
|
|
4
4
|
import { getTheme, listThemes, darkTheme, hasTheme } from "./themes/index.js";
|
|
5
5
|
import { detectSystemTheme } from "./utils/detectTheme.js";
|
|
6
6
|
import { getSavedTheme, saveTheme } from "./utils/config.js";
|
|
7
|
+
import { KEYS } from "./constants/keybindings.js";
|
|
7
8
|
function resolveTheme(mode) {
|
|
8
9
|
if (mode === "system") {
|
|
9
10
|
const detected = detectSystemTheme();
|
|
@@ -42,7 +43,7 @@ export const ThemeProvider = ({ initialTheme, children, }) => {
|
|
|
42
43
|
}, []);
|
|
43
44
|
// Ctrl+T to cycle theme
|
|
44
45
|
useInput((input, key) => {
|
|
45
|
-
if (key.ctrl && input ===
|
|
46
|
+
if (key.ctrl && input === KEYS.THEME_CYCLE) {
|
|
46
47
|
cycleTheme();
|
|
47
48
|
}
|
|
48
49
|
});
|
|
@@ -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", () => {
|
|
@@ -3,6 +3,7 @@ import React, { useState } from "react";
|
|
|
3
3
|
import { useTheme } from "../ThemeContext.js";
|
|
4
4
|
import { SingleLineTextInput } from "./SingleLineTextInput.js";
|
|
5
5
|
import { t } from "../../i18n/index.js";
|
|
6
|
+
import { KEYS } from "../constants/keybindings.js";
|
|
6
7
|
/**
|
|
7
8
|
* ConfirmationDialog shows a 3-option prompt for session rejection
|
|
8
9
|
* Options: Reject & inform AI, Cancel, or Quit CLI
|
|
@@ -37,20 +38,20 @@ export const ConfirmationDialog = ({ message, onReject, onCancel, onQuit, }) =>
|
|
|
37
38
|
}
|
|
38
39
|
// Arrow key navigation
|
|
39
40
|
if (key.upArrow) {
|
|
40
|
-
setFocusedIndex((prev) => (
|
|
41
|
+
setFocusedIndex((prev) => Math.max(0, prev - 1));
|
|
41
42
|
}
|
|
42
43
|
if (key.downArrow) {
|
|
43
|
-
setFocusedIndex((prev) => (
|
|
44
|
+
setFocusedIndex((prev) => Math.min(options.length - 1, prev + 1));
|
|
44
45
|
}
|
|
45
46
|
// Enter key - select focused option
|
|
46
47
|
if (key.return) {
|
|
47
48
|
options[focusedIndex].action();
|
|
48
49
|
}
|
|
49
50
|
// Letter shortcuts
|
|
50
|
-
if (input
|
|
51
|
+
if (KEYS.CONFIRM_YES.test(input)) {
|
|
51
52
|
setShowReasonInput(true);
|
|
52
53
|
}
|
|
53
|
-
if (input
|
|
54
|
+
if (KEYS.CONFIRM_NO.test(input)) {
|
|
54
55
|
onCancel();
|
|
55
56
|
}
|
|
56
57
|
// Esc key - same as quit
|
|
@@ -2,6 +2,7 @@ import { Box, Text } from "ink";
|
|
|
2
2
|
import React, { useEffect, useState } from "react";
|
|
3
3
|
import { t } from "../../i18n/index.js";
|
|
4
4
|
import { useTheme } from "../ThemeContext.js";
|
|
5
|
+
import { KEY_LABELS } from "../constants/keybindings.js";
|
|
5
6
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
6
7
|
/**
|
|
7
8
|
* Footer component - displays context-aware keybindings
|
|
@@ -23,58 +24,58 @@ export const Footer = ({ focusContext, multiSelect, isReviewScreen = false, show
|
|
|
23
24
|
// Review screen mode
|
|
24
25
|
if (isReviewScreen) {
|
|
25
26
|
return [
|
|
26
|
-
{ key:
|
|
27
|
-
{ key:
|
|
27
|
+
{ key: KEY_LABELS.SUBMIT, action: t("footer.submit") },
|
|
28
|
+
{ key: KEY_LABELS.BACK, action: t("footer.back") },
|
|
28
29
|
];
|
|
29
30
|
}
|
|
30
31
|
// Custom input focused
|
|
31
32
|
if (focusContext === "custom-input") {
|
|
32
33
|
return [
|
|
33
|
-
{ key:
|
|
34
|
-
{ key:
|
|
35
|
-
{ key:
|
|
36
|
-
{ key:
|
|
37
|
-
{ key:
|
|
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") },
|
|
38
39
|
];
|
|
39
40
|
}
|
|
40
41
|
// Elaborate input focused (Enter skips, not newline)
|
|
41
42
|
if (focusContext === "elaborate-input") {
|
|
42
43
|
return [
|
|
43
|
-
{ key:
|
|
44
|
-
{ key:
|
|
44
|
+
{ key: KEY_LABELS.NAVIGATE_OPTIONS, action: t("footer.options") },
|
|
45
|
+
{ key: KEY_LABELS.CURSOR, action: t("footer.cursor") },
|
|
45
46
|
{ key: "Enter/Tab", action: t("footer.next") },
|
|
46
|
-
{ key:
|
|
47
|
+
{ key: KEY_LABELS.REJECT, action: t("footer.reject") },
|
|
47
48
|
];
|
|
48
49
|
}
|
|
49
50
|
// Option focused
|
|
50
51
|
if (focusContext === "option") {
|
|
51
52
|
const bindings = [
|
|
52
|
-
{ key:
|
|
53
|
-
{ key:
|
|
54
|
-
{ key:
|
|
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") },
|
|
55
56
|
];
|
|
56
57
|
if (multiSelect) {
|
|
57
|
-
bindings.push({ key:
|
|
58
|
-
bindings.push({ key:
|
|
58
|
+
bindings.push({ key: KEY_LABELS.SELECT, action: t("footer.toggle") });
|
|
59
|
+
bindings.push({ key: KEY_LABELS.NEXT, action: t("footer.next") });
|
|
59
60
|
}
|
|
60
61
|
else {
|
|
61
|
-
bindings.push({ key:
|
|
62
|
-
bindings.push({ key:
|
|
62
|
+
bindings.push({ key: KEY_LABELS.SELECT, action: t("footer.select") });
|
|
63
|
+
bindings.push({ key: KEY_LABELS.SELECT_NEXT, action: t("footer.selectNext") });
|
|
63
64
|
}
|
|
64
65
|
if (hasRecommendedOptions) {
|
|
65
|
-
bindings.push({ key:
|
|
66
|
+
bindings.push({ key: KEY_LABELS.RECOMMEND, action: t("footer.recommended") });
|
|
66
67
|
}
|
|
67
68
|
// Ctrl+R shows when ANY question in session has recommended (not just current)
|
|
68
69
|
if (hasAnyRecommendedInSession) {
|
|
69
|
-
bindings.push({ key:
|
|
70
|
+
bindings.push({ key: KEY_LABELS.QUICK_SUBMIT, action: t("footer.quickSubmit") });
|
|
70
71
|
}
|
|
71
72
|
if (showSessionSwitching) {
|
|
72
|
-
bindings.push({ key:
|
|
73
|
+
bindings.push({ key: KEY_LABELS.SESSION_SWITCH, action: t("footer.sessions") });
|
|
73
74
|
bindings.push({ key: "1-9", action: t("footer.jump") });
|
|
74
|
-
bindings.push({ key:
|
|
75
|
+
bindings.push({ key: KEY_LABELS.SESSION_LIST, action: t("footer.list") });
|
|
75
76
|
}
|
|
76
|
-
bindings.push({ key:
|
|
77
|
-
bindings.push({ key:
|
|
77
|
+
bindings.push({ key: KEY_LABELS.THEME, action: t("footer.theme") });
|
|
78
|
+
bindings.push({ key: KEY_LABELS.REJECT, action: t("footer.reject") });
|
|
78
79
|
return bindings;
|
|
79
80
|
}
|
|
80
81
|
return [];
|
|
@@ -4,6 +4,7 @@ import { t } from "../../i18n/index.js";
|
|
|
4
4
|
import { useTheme } from "../ThemeContext.js";
|
|
5
5
|
import { Footer } from "./Footer.js";
|
|
6
6
|
import { MarkdownPrompt } from "./MarkdownPrompt.js";
|
|
7
|
+
import { KEYS } from "../constants/keybindings.js";
|
|
7
8
|
/**
|
|
8
9
|
* ReviewScreen displays a summary of all answers for confirmation
|
|
9
10
|
* User can press Enter to confirm and submit, or 'n' to go back and edit
|
|
@@ -32,7 +33,7 @@ export const ReviewScreen = ({ answers, elapsedLabel, onConfirm, onGoBack, quest
|
|
|
32
33
|
});
|
|
33
34
|
onConfirm(userAnswers);
|
|
34
35
|
}
|
|
35
|
-
if (input
|
|
36
|
+
if (KEYS.GO_BACK.test(input)) {
|
|
36
37
|
onGoBack();
|
|
37
38
|
}
|
|
38
39
|
});
|
|
@@ -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
|
};
|
|
@@ -2,6 +2,7 @@ import { Box, Text, useInput, useStdout } from "ink";
|
|
|
2
2
|
import React, { useEffect, useState } from "react";
|
|
3
3
|
import { useTheme } from "../ThemeContext.js";
|
|
4
4
|
import { formatRelativeTime } from "../utils/relativeTime.js";
|
|
5
|
+
import { KEYS } from "../constants/keybindings.js";
|
|
5
6
|
/* ------------------------------------------------------------------ */
|
|
6
7
|
/* Helpers */
|
|
7
8
|
/* ------------------------------------------------------------------ */
|
|
@@ -69,7 +70,7 @@ export const SessionPicker = ({ isOpen, sessions, activeIndex, sessionUIStates,
|
|
|
69
70
|
else {
|
|
70
71
|
// Direct number jump (1-9)
|
|
71
72
|
const num = parseInt(input, 10);
|
|
72
|
-
if (num >=
|
|
73
|
+
if (num >= KEYS.SESSION_JUMP_MIN && num <= Math.min(KEYS.SESSION_JUMP_MAX, sessions.length)) {
|
|
73
74
|
onSelectIndex(num - 1);
|
|
74
75
|
onClose();
|
|
75
76
|
}
|
|
@@ -134,23 +135,31 @@ export const SessionPicker = ({ isOpen, sessions, activeIndex, sessionUIStates,
|
|
|
134
135
|
: answered > 0
|
|
135
136
|
? theme.components.sessionPicker.progress
|
|
136
137
|
: theme.components.sessionPicker.rowDim;
|
|
137
|
-
return (React.createElement(Box, { key: session.sessionId },
|
|
138
|
-
React.createElement(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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")))));
|
|
154
163
|
}),
|
|
155
164
|
needsScroll && scrollOffset + maxVisibleRows < sessions.length && (React.createElement(Box, { justifyContent: "center" },
|
|
156
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";
|
|
@@ -7,6 +7,7 @@ import { getSessionDirectory } from "../../session/utils.js";
|
|
|
7
7
|
import { useTheme } from "../ThemeContext.js";
|
|
8
8
|
import { useConfig } from "../ConfigContext.js";
|
|
9
9
|
import { isRecommendedOption } from "../utils/recommended.js";
|
|
10
|
+
import { KEYS } from "../constants/keybindings.js";
|
|
10
11
|
import { ConfirmationDialog } from "./ConfirmationDialog.js";
|
|
11
12
|
import { QuestionDisplay } from "./QuestionDisplay.js";
|
|
12
13
|
import { ReviewScreen } from "./ReviewScreen.js";
|
|
@@ -14,7 +15,7 @@ import { ReviewScreen } from "./ReviewScreen.js";
|
|
|
14
15
|
* StepperView orchestrates the question-answering flow
|
|
15
16
|
* Manages state for current question, answers, and navigation
|
|
16
17
|
*/
|
|
17
|
-
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, }) => {
|
|
18
19
|
const { theme } = useTheme();
|
|
19
20
|
const config = useConfig();
|
|
20
21
|
const { exit } = useApp();
|
|
@@ -23,6 +24,9 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
|
|
|
23
24
|
const [showReview, setShowReview] = useState(false);
|
|
24
25
|
const [submitting, setSubmitting] = useState(false);
|
|
25
26
|
const [showRejectionConfirm, setShowRejectionConfirm] = useState(false);
|
|
27
|
+
const [showAbandonedConfirm, setShowAbandonedConfirm] = useState(false);
|
|
28
|
+
const [abandonedConfirmed, setAbandonedConfirmed] = useState(false);
|
|
29
|
+
const [abandonedFocusedIndex, setAbandonedFocusedIndex] = useState(0);
|
|
26
30
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
|
27
31
|
const [focusContext, setFocusContext] = useState("option");
|
|
28
32
|
const [focusedOptionIndex, setFocusedOptionIndex] = useState(0);
|
|
@@ -179,6 +183,16 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
|
|
|
179
183
|
const anyHasRecommended = sessionRequest.questions.some((question) => question.options.some((opt) => isRecommendedOption(opt.label)));
|
|
180
184
|
setHasAnyRecommendedInSession(anyHasRecommended);
|
|
181
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]);
|
|
182
196
|
useEffect(() => {
|
|
183
197
|
if (!onStateSnapshot) {
|
|
184
198
|
return;
|
|
@@ -206,8 +220,8 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
|
|
|
206
220
|
showReview,
|
|
207
221
|
]);
|
|
208
222
|
useEffect(() => {
|
|
209
|
-
onFlowStateChange?.({ showReview, showRejectionConfirm });
|
|
210
|
-
}, [onFlowStateChange, showRejectionConfirm, showReview]);
|
|
223
|
+
onFlowStateChange?.({ showReview, showRejectionConfirm, showAbandonedConfirm });
|
|
224
|
+
}, [onFlowStateChange, showRejectionConfirm, showReview, showAbandonedConfirm]);
|
|
211
225
|
// Update elapsed time since session creation
|
|
212
226
|
// IMPORTANT: Pause when content overflows terminal to prevent scroll-snapping
|
|
213
227
|
useEffect(() => {
|
|
@@ -342,10 +356,35 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
|
|
|
342
356
|
return newMarks;
|
|
343
357
|
});
|
|
344
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
|
+
});
|
|
345
384
|
// Global keyboard shortcuts and navigation
|
|
346
385
|
useInput((input, key) => {
|
|
347
386
|
// Don't handle navigation when showing review, submitting, or confirming rejection
|
|
348
|
-
if (showReview || submitting || showRejectionConfirm)
|
|
387
|
+
if (showReview || submitting || showRejectionConfirm || showAbandonedConfirm)
|
|
349
388
|
return;
|
|
350
389
|
// Derive text-input state from both focusContext and focusedOptionIndex
|
|
351
390
|
// focusContext may lag by one render cycle (set via useEffect in OptionsList)
|
|
@@ -358,7 +397,7 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
|
|
|
358
397
|
return;
|
|
359
398
|
}
|
|
360
399
|
// Ctrl+R: Quick submit with recommended options (select all recommended and go to review)
|
|
361
|
-
if (input.toLowerCase() ===
|
|
400
|
+
if (input.toLowerCase() === KEYS.QUICK_SUBMIT &&
|
|
362
401
|
key.ctrl &&
|
|
363
402
|
hasAnyRecommendedInSession &&
|
|
364
403
|
!isInTextInput) {
|
|
@@ -395,7 +434,7 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
|
|
|
395
434
|
return;
|
|
396
435
|
}
|
|
397
436
|
// R key: Select recommended options for current question
|
|
398
|
-
if (input.toLowerCase() ===
|
|
437
|
+
if (input.toLowerCase() === KEYS.RECOMMEND &&
|
|
399
438
|
!key.ctrl &&
|
|
400
439
|
!isInTextInput &&
|
|
401
440
|
hasRecommendedOptions) {
|
|
@@ -480,6 +519,31 @@ export const StepperView = ({ onComplete, onProgress, hasMultipleSessions, initi
|
|
|
480
519
|
}
|
|
481
520
|
}
|
|
482
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
|
+
}
|
|
483
547
|
// Show rejection confirmation
|
|
484
548
|
if (showRejectionConfirm) {
|
|
485
549
|
return (React.createElement(Box, { flexDirection: "column", padding: 1 },
|