auq-mcp-server 2.6.3 → 2.7.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.
Files changed (96) hide show
  1. package/README.md +56 -2
  2. package/dist/bin/auq.js +36 -3
  3. package/dist/bin/tui-app.js +30 -15
  4. package/dist/package.json +7 -2
  5. package/dist/src/__tests__/schema-validation.test.js +61 -1
  6. package/dist/src/cli/commands/__tests__/fetch-answers.test.js +310 -0
  7. package/dist/src/cli/commands/__tests__/history.test.js +211 -0
  8. package/dist/src/cli/commands/answer.js +11 -0
  9. package/dist/src/cli/commands/config.js +48 -0
  10. package/dist/src/cli/commands/fetch-answers.js +205 -0
  11. package/dist/src/cli/commands/history.js +375 -0
  12. package/dist/src/config/__tests__/ConfigLoader.test.js +38 -0
  13. package/dist/src/config/defaults.js +1 -0
  14. package/dist/src/config/types.js +1 -0
  15. package/dist/src/core/ask-user-questions.js +63 -0
  16. package/dist/src/i18n/locales/en.js +2 -2
  17. package/dist/src/server.js +59 -2
  18. package/dist/src/session/ResponseFormatter.js +79 -2
  19. package/dist/src/session/SessionManager.js +36 -0
  20. package/dist/src/session/__tests__/ResponseFormatter.test.js +86 -0
  21. package/dist/src/session/__tests__/SessionManager.test.js +129 -0
  22. package/dist/src/shared/schemas.js +8 -0
  23. package/dist/src/tui/ThemeProvider.js +3 -3
  24. package/dist/src/tui/components/Header.js +2 -1
  25. package/dist/src/tui/components/OptionsList.js +1 -1
  26. package/dist/src/tui/components/SessionPicker.js +1 -1
  27. package/dist/src/tui/components/StepperView.js +1 -1
  28. package/dist/src/tui/components/__tests__/ConfirmationDialog.test.js +1 -1
  29. package/dist/src/tui/components/__tests__/Footer.test.js +1 -1
  30. package/dist/src/tui/components/__tests__/MarkdownPrompt.test.js +1 -1
  31. package/dist/src/tui/components/__tests__/ReviewScreen.test.js +1 -1
  32. package/dist/src/tui/components/__tests__/SessionDots.test.js +1 -1
  33. package/dist/src/tui/components/__tests__/SessionPicker.test.js +1 -1
  34. package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +1 -1
  35. package/dist/src/tui/components/__tests__/StepperView.keyboard.test.js +1 -1
  36. package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -1
  37. package/dist/src/tui/components/__tests__/WaitingScreen.test.js +1 -1
  38. package/dist/src/tui/shared/session-events.js +4 -0
  39. package/dist/src/tui/shared/themes/catppuccin-latte.js +130 -0
  40. package/dist/src/tui/shared/themes/catppuccin-mocha.js +131 -0
  41. package/dist/src/tui/shared/themes/dark.js +131 -0
  42. package/dist/src/tui/shared/themes/dracula.js +131 -0
  43. package/dist/src/tui/shared/themes/github-dark.js +129 -0
  44. package/dist/src/tui/shared/themes/github-light.js +129 -0
  45. package/dist/src/tui/shared/themes/gruvbox-dark.js +130 -0
  46. package/dist/src/tui/shared/themes/gruvbox-light.js +130 -0
  47. package/dist/src/tui/shared/themes/index.js +70 -0
  48. package/dist/src/tui/shared/themes/light.js +130 -0
  49. package/dist/src/tui/shared/themes/loader.js +111 -0
  50. package/dist/src/tui/shared/themes/monokai.js +132 -0
  51. package/dist/src/tui/shared/themes/nord.js +130 -0
  52. package/dist/src/tui/shared/themes/one-dark.js +131 -0
  53. package/dist/src/tui/shared/themes/rose-pine.js +131 -0
  54. package/dist/src/tui/shared/themes/solarized-dark.js +130 -0
  55. package/dist/src/tui/shared/themes/solarized-light.js +130 -0
  56. package/dist/src/tui/shared/themes/tokyo-night.js +131 -0
  57. package/dist/src/tui/shared/themes/types.js +1 -0
  58. package/dist/src/tui/shared/types.js +1 -0
  59. package/dist/src/tui/shared/utils/config.js +80 -0
  60. package/dist/src/tui/shared/utils/detectTheme.js +33 -0
  61. package/dist/src/tui/shared/utils/index.js +6 -0
  62. package/dist/src/tui/shared/utils/recommended.js +52 -0
  63. package/dist/src/tui/shared/utils/relativeTime.js +24 -0
  64. package/dist/src/tui/shared/utils/sessionSwitching.js +56 -0
  65. package/dist/src/tui/shared/utils/staleDetection.js +51 -0
  66. package/dist/src/tui/themes/catppuccin-latte.js +2 -127
  67. package/dist/src/tui/themes/catppuccin-mocha.js +2 -127
  68. package/dist/src/tui/themes/dark.js +2 -128
  69. package/dist/src/tui/themes/dracula.js +2 -127
  70. package/dist/src/tui/themes/github-dark.js +2 -126
  71. package/dist/src/tui/themes/github-light.js +2 -126
  72. package/dist/src/tui/themes/gruvbox-dark.js +2 -127
  73. package/dist/src/tui/themes/gruvbox-light.js +2 -127
  74. package/dist/src/tui/themes/index.js +2 -70
  75. package/dist/src/tui/themes/light.js +2 -127
  76. package/dist/src/tui/themes/loader.js +2 -111
  77. package/dist/src/tui/themes/monokai.js +2 -128
  78. package/dist/src/tui/themes/nord.js +2 -127
  79. package/dist/src/tui/themes/one-dark.js +2 -127
  80. package/dist/src/tui/themes/rose-pine.js +2 -128
  81. package/dist/src/tui/themes/solarized-dark.js +2 -127
  82. package/dist/src/tui/themes/solarized-light.js +2 -127
  83. package/dist/src/tui/themes/tokyo-night.js +2 -127
  84. package/dist/src/tui/themes/types.js +2 -1
  85. package/dist/src/tui/types.js +1 -1
  86. package/dist/src/tui/utils/__tests__/recommended.test.js +1 -1
  87. package/dist/src/tui/utils/__tests__/relativeTime.test.js +1 -1
  88. package/dist/src/tui/utils/__tests__/sessionSwitching.test.js +1 -1
  89. package/dist/src/tui/utils/__tests__/staleDetection.test.js +1 -1
  90. package/dist/src/tui/utils/config.js +1 -80
  91. package/dist/src/tui/utils/detectTheme.js +1 -22
  92. package/dist/src/tui/utils/recommended.js +1 -52
  93. package/dist/src/tui/utils/relativeTime.js +1 -24
  94. package/dist/src/tui/utils/sessionSwitching.js +1 -56
  95. package/dist/src/tui/utils/staleDetection.js +1 -51
  96. package/package.json +7 -2
@@ -0,0 +1,205 @@
1
+ /**
2
+ * CLI Fetch-Answers Command — `auq fetch-answers [<session-id>] [--blocking] [--json] [--unread]`
3
+ * Retrieves answers for a session asynchronously, or lists unread sessions.
4
+ */
5
+ import { SessionManager } from "../../session/SessionManager.js";
6
+ import { ResponseFormatter } from "../../session/ResponseFormatter.js";
7
+ import { getSessionDirectory } from "../../session/utils.js";
8
+ import { formatAge, outputResult, parseFlags } from "../utils.js";
9
+ /**
10
+ * Run the `auq fetch-answers` command.
11
+ *
12
+ * Usage:
13
+ * auq fetch-answers # list all unread sessions (default)
14
+ * auq fetch-answers --unread # same as above
15
+ * auq fetch-answers <sessionId> # fetch answers for specific session
16
+ * auq fetch-answers <sessionId> --blocking # wait until answered, then fetch
17
+ * auq fetch-answers <sessionId> --json # output as JSON
18
+ */
19
+ export async function runFetchAnswersCommand(args) {
20
+ const { flags, positionals } = parseFlags(args);
21
+ const jsonMode = flags.json === true;
22
+ const blockingMode = flags.blocking === true;
23
+ const unreadMode = flags.unread === true;
24
+ const sessionIdArg = positionals[0];
25
+ // ── Initialise SessionManager ─────────────────────────────────────
26
+ const sessionManager = new SessionManager({
27
+ baseDir: getSessionDirectory(),
28
+ });
29
+ await sessionManager.initialize();
30
+ // ── Mode B: List unread sessions ──────────────────────────────────
31
+ // Default (no session-id) OR explicit --unread flag
32
+ if (!sessionIdArg || unreadMode) {
33
+ // Guard: if --blocking was passed without a session ID, that's an error
34
+ if (blockingMode && !sessionIdArg) {
35
+ outputResult({ success: false, error: "--blocking requires a session ID.\n\nUsage:\n auq fetch-answers <session-id> [--blocking] [--json]\n auq fetch-answers --unread [--json]" }, jsonMode);
36
+ process.exitCode = 1;
37
+ return;
38
+ }
39
+ return listUnreadSessions(sessionManager, jsonMode);
40
+ }
41
+ // ── Mode A: Fetch specific session ────────────────────────────────
42
+ return fetchSession(sessionManager, sessionIdArg, blockingMode, jsonMode);
43
+ }
44
+ // ── List Unread Sessions ──────────────────────────────────────────────
45
+ async function listUnreadSessions(sessionManager, jsonMode) {
46
+ const unreadIds = await sessionManager.getUnreadSessions();
47
+ if (jsonMode) {
48
+ const entries = await Promise.all(unreadIds.map(async (sessionId) => {
49
+ const status = await sessionManager.getSessionStatus(sessionId);
50
+ const request = await sessionManager.getSessionRequest(sessionId);
51
+ return {
52
+ sessionId,
53
+ status: status?.status ?? "unknown",
54
+ createdAt: status?.createdAt ?? null,
55
+ lastReadAt: null,
56
+ questionCount: request?.questions?.length ?? status?.totalQuestions ?? 0,
57
+ };
58
+ }));
59
+ console.log(JSON.stringify(entries, null, 2));
60
+ return;
61
+ }
62
+ if (unreadIds.length === 0) {
63
+ console.log("No unread sessions.");
64
+ return;
65
+ }
66
+ console.log("Unread sessions:");
67
+ console.log("");
68
+ // Build table rows
69
+ const rows = [];
70
+ for (const sessionId of unreadIds) {
71
+ const status = await sessionManager.getSessionStatus(sessionId);
72
+ const request = await sessionManager.getSessionRequest(sessionId);
73
+ rows.push({
74
+ id: sessionId.slice(0, 8),
75
+ status: status?.status ?? "unknown",
76
+ age: status ? formatAge(status.createdAt) : "?",
77
+ questions: request?.questions?.length ?? status?.totalQuestions ?? 0,
78
+ });
79
+ }
80
+ // Print header
81
+ console.log("ID Status Age Questions");
82
+ for (const row of rows) {
83
+ const id = row.id.padEnd(10);
84
+ const status = row.status.padEnd(11);
85
+ const age = row.age.padEnd(8);
86
+ console.log(`${id}${status}${age}${row.questions}`);
87
+ }
88
+ }
89
+ // ── Fetch Specific Session ────────────────────────────────────────────
90
+ async function fetchSession(sessionManager, sessionIdArg, blockingMode, jsonMode) {
91
+ // ── Resolve session ID (full UUID or 8-char short prefix) ─────────
92
+ let sessionId;
93
+ if (sessionIdArg.length === 8) {
94
+ // Try prefix resolution
95
+ const allIds = await sessionManager.getAllSessionIds();
96
+ const match = allIds.find((id) => id.startsWith(sessionIdArg));
97
+ if (!match) {
98
+ outputResult({ success: false, error: `Session not found: ${sessionIdArg}` }, jsonMode);
99
+ process.exitCode = 1;
100
+ return;
101
+ }
102
+ sessionId = match;
103
+ }
104
+ else {
105
+ // Treat as full UUID
106
+ const exists = await sessionManager.sessionExists(sessionIdArg);
107
+ if (!exists) {
108
+ outputResult({ success: false, error: `Session not found: ${sessionIdArg}` }, jsonMode);
109
+ process.exitCode = 1;
110
+ return;
111
+ }
112
+ sessionId = sessionIdArg;
113
+ }
114
+ // ── Get current status ────────────────────────────────────────────
115
+ let status = await sessionManager.getSessionStatus(sessionId);
116
+ if (!status) {
117
+ outputResult({ success: false, error: `Could not read session data for: ${sessionId}` }, jsonMode);
118
+ process.exitCode = 1;
119
+ return;
120
+ }
121
+ // ── Handle blocking wait for pending/in-progress sessions ─────────
122
+ const isPending = status.status === "pending" || status.status === "in-progress";
123
+ if (isPending && blockingMode) {
124
+ try {
125
+ await sessionManager.waitForAnswers(sessionId);
126
+ // Re-read status after wait
127
+ status = await sessionManager.getSessionStatus(sessionId);
128
+ }
129
+ catch (err) {
130
+ const errMsg = err instanceof Error ? err.message : String(err);
131
+ if (errMsg === "SESSION_REJECTED") {
132
+ // Will be handled below after re-reading status
133
+ status = await sessionManager.getSessionStatus(sessionId);
134
+ }
135
+ else {
136
+ outputResult({ success: false, error: `Error waiting for answers: ${errMsg}` }, jsonMode);
137
+ process.exitCode = 1;
138
+ return;
139
+ }
140
+ }
141
+ }
142
+ // Re-check status after potential wait
143
+ const currentStatus = status?.status ?? "unknown";
144
+ // ── Handle completed session ──────────────────────────────────────
145
+ if (currentStatus === "completed") {
146
+ const request = await sessionManager.getSessionRequest(sessionId);
147
+ let answersData = null;
148
+ try {
149
+ answersData = await sessionManager.getSessionAnswers(sessionId);
150
+ }
151
+ catch {
152
+ // answers.json may not exist yet, treat as pending
153
+ }
154
+ if (!answersData || !request) {
155
+ outputResult({ success: false, error: `Could not read answers for: ${sessionId}` }, jsonMode);
156
+ process.exitCode = 1;
157
+ return;
158
+ }
159
+ // Mark as read
160
+ await sessionManager.markSessionAsRead(sessionId);
161
+ const lastReadAt = new Date().toISOString();
162
+ if (jsonMode) {
163
+ console.log(JSON.stringify({
164
+ sessionId,
165
+ status: "completed",
166
+ answers: answersData.answers,
167
+ lastReadAt,
168
+ }, null, 2));
169
+ return;
170
+ }
171
+ const formatted = ResponseFormatter.formatUserResponse(answersData, request.questions, sessionId);
172
+ console.log(formatted);
173
+ return;
174
+ }
175
+ // ── Handle pending/in-progress (non-blocking) ─────────────────────
176
+ if (currentStatus === "pending" || currentStatus === "in-progress") {
177
+ if (jsonMode) {
178
+ console.log(JSON.stringify({ sessionId, status: currentStatus, answers: null, lastReadAt: null }, null, 2));
179
+ return;
180
+ }
181
+ console.log(ResponseFormatter.formatPendingStatus(sessionId));
182
+ return;
183
+ }
184
+ // ── Handle rejected session ───────────────────────────────────────
185
+ if (currentStatus === "rejected") {
186
+ if (jsonMode) {
187
+ console.log(JSON.stringify({
188
+ sessionId,
189
+ status: "rejected",
190
+ answers: null,
191
+ lastReadAt: null,
192
+ rejectionReason: status?.rejectionReason ?? null,
193
+ }, null, 2));
194
+ return;
195
+ }
196
+ console.log(ResponseFormatter.formatRejectedStatus(sessionId, status?.rejectionReason));
197
+ return;
198
+ }
199
+ // ── Handle abandoned / timed_out ──────────────────────────────────
200
+ if (jsonMode) {
201
+ console.log(JSON.stringify({ sessionId, status: currentStatus, answers: null, lastReadAt: null }, null, 2));
202
+ return;
203
+ }
204
+ console.log(ResponseFormatter.formatSessionStatus(sessionId, currentStatus));
205
+ }
@@ -0,0 +1,375 @@
1
+ /**
2
+ * CLI History Command — `auq history` and `auq history show <id>`
3
+ * List and browse historical sessions with filtering and search.
4
+ */
5
+ import chalk from "chalk";
6
+ import { SessionManager } from "../../session/SessionManager.js";
7
+ import { getSessionDirectory } from "../../session/utils.js";
8
+ import { formatAge, parseFlags } from "../utils.js";
9
+ // ── Helper Functions ────────────────────────────────────────────────
10
+ function getStatusIndicator(status) {
11
+ switch (status) {
12
+ case "completed":
13
+ return chalk.green("✓ completed");
14
+ case "rejected":
15
+ return chalk.red("✗ rejected");
16
+ case "pending":
17
+ return chalk.yellow("⏳ pending");
18
+ case "in-progress":
19
+ return chalk.yellow("⏳ in-progress");
20
+ case "timed_out":
21
+ return chalk.yellow("⏱ timed_out");
22
+ case "abandoned":
23
+ return chalk.dim("… abandoned");
24
+ default:
25
+ return status;
26
+ }
27
+ }
28
+ function getReadIndicator(lastReadAt) {
29
+ return lastReadAt ? "✓" : "─";
30
+ }
31
+ function truncatePreview(text, maxLen) {
32
+ if (text.length <= maxLen)
33
+ return text;
34
+ return text.slice(0, maxLen - 1) + "…";
35
+ }
36
+ function padColumn(text, width) {
37
+ // Strip ANSI escape codes for length calculation
38
+ const plainText = text.replace(/\x1b\[[0-9;]*m/g, "");
39
+ const padding = Math.max(0, width - plainText.length);
40
+ return text + " ".repeat(padding);
41
+ }
42
+ async function resolveSessionId(sessionManager, idArg) {
43
+ // Try exact full UUID match first
44
+ const exists = await sessionManager.sessionExists(idArg);
45
+ if (exists)
46
+ return idArg;
47
+ // Try short ID prefix match
48
+ const allIds = await sessionManager.getAllSessionIds();
49
+ const shortMatches = allIds.filter((id) => id.startsWith(idArg));
50
+ if (shortMatches.length === 1)
51
+ return shortMatches[0];
52
+ return null;
53
+ }
54
+ // ── List History ────────────────────────────────────────────────────
55
+ async function listHistory(_positionals, flags) {
56
+ const jsonMode = flags.json === true;
57
+ const showAll = flags.all === true;
58
+ const filterUnread = flags.unread === true;
59
+ const sessionFilter = typeof flags.session === "string" ? flags.session : undefined;
60
+ const searchFilter = typeof flags.search === "string" ? flags.search.toLowerCase() : undefined;
61
+ const limitRaw = typeof flags.limit === "string" ? parseInt(flags.limit, 10) : 20;
62
+ const limit = isNaN(limitRaw) ? 20 : Math.max(1, limitRaw);
63
+ // Initialize SessionManager
64
+ const sessionManager = new SessionManager({
65
+ baseDir: getSessionDirectory(),
66
+ });
67
+ await sessionManager.initialize();
68
+ // Get all session IDs
69
+ const sessionIds = await sessionManager.getAllSessionIds();
70
+ // Build entries
71
+ const entries = [];
72
+ let abandonedCount = 0;
73
+ for (const sessionId of sessionIds) {
74
+ let status = null;
75
+ let request = null;
76
+ let answersData = null;
77
+ try {
78
+ status = await sessionManager.getSessionStatus(sessionId);
79
+ if (!status)
80
+ continue;
81
+ request = await sessionManager.getSessionRequest(sessionId);
82
+ try {
83
+ answersData = await sessionManager.getSessionAnswers(sessionId);
84
+ }
85
+ catch {
86
+ // answers.json may not exist — that's ok
87
+ }
88
+ }
89
+ catch {
90
+ continue; // Skip broken sessions
91
+ }
92
+ if (!status)
93
+ continue;
94
+ const isAbandoned = status.status === "abandoned";
95
+ if (isAbandoned)
96
+ abandonedCount++;
97
+ const questionCount = request?.questions?.length ?? status.totalQuestions ?? 0;
98
+ const answeredCount = answersData?.answers?.length ?? 0;
99
+ const lastReadAt = answersData?.lastReadAt;
100
+ // Build preview from first question prompt
101
+ const firstQuestion = request?.questions?.[0];
102
+ const preview = firstQuestion
103
+ ? truncatePreview(firstQuestion.prompt, 40)
104
+ : "";
105
+ // Build search text from all question prompts and answer data
106
+ const searchTextParts = [];
107
+ if (request?.questions) {
108
+ for (const q of request.questions) {
109
+ searchTextParts.push(q.prompt.toLowerCase());
110
+ if (q.title)
111
+ searchTextParts.push(q.title.toLowerCase());
112
+ }
113
+ }
114
+ if (answersData?.answers) {
115
+ for (const a of answersData.answers) {
116
+ if (a.selectedOption)
117
+ searchTextParts.push(a.selectedOption.toLowerCase());
118
+ if (a.selectedOptions) {
119
+ for (const opt of a.selectedOptions)
120
+ searchTextParts.push(opt.toLowerCase());
121
+ }
122
+ if (a.customText)
123
+ searchTextParts.push(a.customText.toLowerCase());
124
+ }
125
+ }
126
+ const searchText = searchTextParts.join(" ");
127
+ entries.push({
128
+ sessionId,
129
+ shortId: sessionId.slice(0, 8),
130
+ status: status.status,
131
+ createdAt: status.createdAt,
132
+ lastReadAt,
133
+ questionCount,
134
+ answeredCount,
135
+ preview,
136
+ searchText,
137
+ isAbandoned,
138
+ });
139
+ }
140
+ // Sort by createdAt descending (newest first)
141
+ entries.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
142
+ // Apply filters
143
+ let filtered = [...entries];
144
+ // Base filter: exclude abandoned unless --all
145
+ if (!showAll) {
146
+ filtered = filtered.filter((e) => !e.isAbandoned);
147
+ }
148
+ // --session filter
149
+ if (sessionFilter !== undefined) {
150
+ filtered = filtered.filter((e) => e.sessionId === sessionFilter ||
151
+ e.sessionId.startsWith(sessionFilter));
152
+ if (filtered.length === 0) {
153
+ if (jsonMode) {
154
+ console.log(JSON.stringify([], null, 2));
155
+ }
156
+ else {
157
+ console.error(`Session not found: ${sessionFilter}`);
158
+ }
159
+ process.exitCode = 1;
160
+ return;
161
+ }
162
+ }
163
+ // --unread filter
164
+ if (filterUnread) {
165
+ filtered = filtered.filter((e) => e.status === "completed" && !e.lastReadAt);
166
+ }
167
+ // --search filter
168
+ if (searchFilter !== undefined) {
169
+ filtered = filtered.filter((e) => e.searchText.includes(searchFilter));
170
+ }
171
+ // Apply limit
172
+ const displayed = filtered.slice(0, limit);
173
+ // JSON output
174
+ if (jsonMode) {
175
+ const result = displayed.map((e) => ({
176
+ sessionId: e.sessionId,
177
+ status: e.status,
178
+ createdAt: e.createdAt,
179
+ lastReadAt: e.lastReadAt ?? null,
180
+ questionCount: e.questionCount,
181
+ answeredCount: e.answeredCount,
182
+ preview: e.preview,
183
+ }));
184
+ console.log(JSON.stringify(result, null, 2));
185
+ return;
186
+ }
187
+ // Empty state
188
+ if (displayed.length === 0) {
189
+ console.log("No sessions found.");
190
+ return;
191
+ }
192
+ // Table output
193
+ const headerLine = padColumn("ID", 10) +
194
+ padColumn("Status", 16) +
195
+ padColumn("Time", 10) +
196
+ padColumn("Read", 7) +
197
+ padColumn("Q", 6) +
198
+ "Preview";
199
+ console.log(chalk.dim(headerLine));
200
+ for (const entry of displayed) {
201
+ const statusStr = getStatusIndicator(entry.status);
202
+ const age = formatAge(entry.createdAt);
203
+ const readStr = getReadIndicator(entry.lastReadAt);
204
+ const questionsStr = `${entry.answeredCount}/${entry.questionCount}`;
205
+ const line = padColumn(entry.shortId, 10) +
206
+ padColumn(statusStr, 16) +
207
+ padColumn(age, 10) +
208
+ padColumn(readStr, 7) +
209
+ padColumn(questionsStr, 6) +
210
+ entry.preview;
211
+ console.log(line);
212
+ }
213
+ // Hint line when abandoned sessions are hidden
214
+ if (!showAll && abandonedCount > 0) {
215
+ console.log(chalk.dim(`\n${displayed.length} sessions shown (${abandonedCount} abandoned hidden, use --all)`));
216
+ }
217
+ }
218
+ // ── Show History ────────────────────────────────────────────────────
219
+ async function showHistory(positionals, flags) {
220
+ const jsonMode = flags.json === true;
221
+ const idArg = positionals[0];
222
+ if (!idArg) {
223
+ console.error("Missing session ID. Usage: auq history show <sessionId> [--json]");
224
+ process.exitCode = 1;
225
+ return;
226
+ }
227
+ // Initialize SessionManager
228
+ const sessionManager = new SessionManager({
229
+ baseDir: getSessionDirectory(),
230
+ });
231
+ await sessionManager.initialize();
232
+ // Resolve session ID (full UUID or short prefix)
233
+ const sessionId = await resolveSessionId(sessionManager, idArg);
234
+ if (!sessionId) {
235
+ console.error(`Session not found: ${idArg}`);
236
+ process.exitCode = 1;
237
+ return;
238
+ }
239
+ // Load session data
240
+ const status = await sessionManager.getSessionStatus(sessionId);
241
+ const request = await sessionManager.getSessionRequest(sessionId);
242
+ let answersData = null;
243
+ try {
244
+ answersData = await sessionManager.getSessionAnswers(sessionId);
245
+ }
246
+ catch {
247
+ // answers.json may not exist for pending/abandoned sessions
248
+ }
249
+ if (!status || !request) {
250
+ console.error(`Could not read session data for: ${idArg}`);
251
+ process.exitCode = 1;
252
+ return;
253
+ }
254
+ const questions = request.questions;
255
+ const answers = answersData?.answers ?? null;
256
+ const lastReadAt = answersData?.lastReadAt;
257
+ const answeredCount = answers?.length ?? 0;
258
+ // Build answer lookup (questionIndex → answer)
259
+ const answerMap = new Map();
260
+ if (answers) {
261
+ for (const a of answers) {
262
+ answerMap.set(a.questionIndex, {
263
+ selectedOption: a.selectedOption,
264
+ selectedOptions: a.selectedOptions,
265
+ customText: a.customText,
266
+ });
267
+ }
268
+ }
269
+ // JSON output
270
+ if (jsonMode) {
271
+ const result = {
272
+ sessionId,
273
+ status: status.status,
274
+ createdAt: status.createdAt,
275
+ lastReadAt: lastReadAt ?? null,
276
+ questionCount: questions.length,
277
+ answeredCount,
278
+ questions: questions.map((q, i) => {
279
+ const answer = answerMap.get(i);
280
+ const selectedLabels = new Set();
281
+ if (answer?.selectedOption)
282
+ selectedLabels.add(answer.selectedOption);
283
+ if (answer?.selectedOptions) {
284
+ for (const opt of answer.selectedOptions)
285
+ selectedLabels.add(opt);
286
+ }
287
+ return {
288
+ index: i,
289
+ title: q.title,
290
+ prompt: q.prompt,
291
+ multiSelect: q.multiSelect ?? false,
292
+ options: q.options.map((o) => ({
293
+ label: o.label,
294
+ description: o.description ?? null,
295
+ selected: selectedLabels.has(o.label),
296
+ })),
297
+ customText: answer?.customText ?? null,
298
+ };
299
+ }),
300
+ };
301
+ console.log(JSON.stringify(result, null, 2));
302
+ return;
303
+ }
304
+ // Human-readable output
305
+ const age = formatAge(status.createdAt);
306
+ const createdAbsolute = new Date(status.createdAt)
307
+ .toISOString()
308
+ .replace("T", " ")
309
+ .replace(/\.\d+Z$/, "Z");
310
+ console.log(`Session: ${sessionId}`);
311
+ console.log(`Status: ${getStatusIndicator(status.status)}`);
312
+ console.log(`Created: ${createdAbsolute} (${age})`);
313
+ if (lastReadAt) {
314
+ const readAbsolute = new Date(lastReadAt)
315
+ .toISOString()
316
+ .replace("T", " ")
317
+ .replace(/\.\d+Z$/, "Z");
318
+ console.log(`Read: ✓ ${readAbsolute}`);
319
+ }
320
+ else {
321
+ console.log("Read: unread");
322
+ }
323
+ console.log(`Questions: ${answeredCount}/${questions.length} answered`);
324
+ console.log("");
325
+ // Display questions with options
326
+ for (let i = 0; i < questions.length; i++) {
327
+ const q = questions[i];
328
+ const answer = answerMap.get(i);
329
+ // Determine which option labels are selected
330
+ const selectedLabels = new Set();
331
+ if (answer?.selectedOption)
332
+ selectedLabels.add(answer.selectedOption);
333
+ if (answer?.selectedOptions) {
334
+ for (const opt of answer.selectedOptions)
335
+ selectedLabels.add(opt);
336
+ }
337
+ console.log(`${i + 1}. ${q.title}`);
338
+ console.log(` ${q.prompt}`);
339
+ let otherOptionHandled = false;
340
+ for (const opt of q.options) {
341
+ const isSelected = selectedLabels.has(opt.label);
342
+ const descPart = opt.description ? ` — ${opt.description}` : "";
343
+ // If this is the "Other" option and there's custom text, show custom text inline
344
+ if (isSelected && answer?.customText && opt.label === "Other") {
345
+ console.log(` (selected) Other: '${answer.customText}'`);
346
+ otherOptionHandled = true;
347
+ }
348
+ else if (isSelected) {
349
+ console.log(` (selected) ${opt.label}${descPart}`);
350
+ }
351
+ else {
352
+ console.log(` ${opt.label}${descPart}`);
353
+ }
354
+ }
355
+ // If customText exists but "Other" is not a listed option, show it as additional entry
356
+ if (answer?.customText && !otherOptionHandled) {
357
+ const hasOtherOption = q.options.some((o) => o.label === "Other");
358
+ if (!hasOtherOption) {
359
+ console.log(` (selected) Other: '${answer.customText}'`);
360
+ }
361
+ }
362
+ console.log("");
363
+ }
364
+ }
365
+ // ── History Command Dispatcher ──────────────────────────────────────
366
+ export async function runHistoryCommand(args) {
367
+ const { flags, positionals } = parseFlags(args);
368
+ const subcommand = positionals[0];
369
+ if (subcommand === "show") {
370
+ await showHistory(positionals.slice(1), flags);
371
+ }
372
+ else {
373
+ await listHistory(positionals, flags);
374
+ }
375
+ }
@@ -132,4 +132,42 @@ describe("ConfigLoader", () => {
132
132
  expect(config.staleThreshold).toBe(DEFAULT_CONFIG.staleThreshold);
133
133
  });
134
134
  });
135
+ describe("renderer config options", () => {
136
+ it("should have 'ink' as default renderer when no config files exist", () => {
137
+ vi.mocked(fs.existsSync).mockReturnValue(false);
138
+ const config = loadConfig();
139
+ expect(config.renderer).toBe("ink");
140
+ });
141
+ it("should include renderer in DEFAULT_CONFIG as 'ink'", () => {
142
+ expect(DEFAULT_CONFIG.renderer).toBe("ink");
143
+ });
144
+ it("should load renderer: 'opentui' from config file", () => {
145
+ vi.mocked(fs.existsSync).mockReturnValue(true);
146
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ renderer: "opentui" }));
147
+ const config = loadConfig();
148
+ expect(config.renderer).toBe("opentui");
149
+ });
150
+ it("should load renderer: 'ink' from config file", () => {
151
+ vi.mocked(fs.existsSync).mockReturnValue(true);
152
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ renderer: "ink" }));
153
+ const config = loadConfig();
154
+ expect(config.renderer).toBe("ink");
155
+ });
156
+ it("should fall back to 'ink' for invalid renderer value", () => {
157
+ vi.mocked(fs.existsSync).mockReturnValue(true);
158
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ renderer: "chrome" }));
159
+ const config = loadConfig();
160
+ // Invalid enum value should be ignored, default ('ink') used
161
+ expect(config.renderer).toBe(DEFAULT_CONFIG.renderer);
162
+ expect(config.renderer).toBe("ink");
163
+ });
164
+ it("should preserve other config values alongside renderer setting", () => {
165
+ vi.mocked(fs.existsSync).mockReturnValue(true);
166
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ renderer: "opentui", theme: "dark" }));
167
+ const config = loadConfig();
168
+ expect(config.renderer).toBe("opentui");
169
+ expect(config.theme).toBe("dark");
170
+ expect(config.maxOptions).toBe(DEFAULT_CONFIG.maxOptions);
171
+ });
172
+ });
135
173
  });
@@ -8,6 +8,7 @@ export const DEFAULT_CONFIG = {
8
8
  language: "auto",
9
9
  theme: "system",
10
10
  autoSelectRecommended: true,
11
+ renderer: "ink",
11
12
  staleThreshold: 7200000, // 2 hours in ms
12
13
  notifyOnStale: true,
13
14
  staleAction: "warn",
@@ -21,6 +21,7 @@ export const AUQConfigSchema = z.object({
21
21
  language: z.string().default("auto"),
22
22
  theme: z.string().default("system"),
23
23
  autoSelectRecommended: z.boolean().default(true),
24
+ renderer: z.enum(["ink", "opentui"]).default("ink"),
24
25
  // Stale/Orphan Session Detection
25
26
  staleThreshold: z.number().min(0).default(7200000), // 2 hours in ms
26
27
  notifyOnStale: z.boolean().default(true),