auq-mcp-server 2.6.4 → 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 +27 -6
  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,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),
@@ -1,4 +1,5 @@
1
1
  import { SessionManager } from "../session/index.js";
2
+ import { ResponseFormatter } from "../session/ResponseFormatter.js";
2
3
  import { getSessionDirectory } from "../session/utils.js";
3
4
  import { AskUserQuestionsParametersSchema, QuestionSchema, QuestionsSchema, } from "../shared/schemas.js";
4
5
  // Re-export schemas for backward compatibility
@@ -27,8 +28,70 @@ export const createAskUserQuestionsCore = (options = {}) => {
27
28
  const parsedQuestions = QuestionsSchema.parse(questions);
28
29
  return sessionManager.startSession(normalizeQuestions(parsedQuestions), callId, workingDirectory, signal);
29
30
  };
31
+ const askNonBlocking = async (questions, callId, workingDirectory) => {
32
+ await ensureInitialized();
33
+ const parsedQuestions = QuestionsSchema.parse(questions);
34
+ const sessionId = await sessionManager.createSession(normalizeQuestions(parsedQuestions), workingDirectory);
35
+ return { sessionId, questionCount: parsedQuestions.length };
36
+ };
37
+ const getAnsweredQuestions = async (sessionId, blocking, signal) => {
38
+ await ensureInitialized();
39
+ // Resolve short ID to full UUID if needed
40
+ let resolvedSessionId = sessionId;
41
+ if (sessionId.length < 36) {
42
+ const allIds = await sessionManager.getAllSessionIds();
43
+ const match = allIds.find((id) => id.startsWith(sessionId));
44
+ if (!match) {
45
+ throw new Error(`Session not found: ${sessionId}`);
46
+ }
47
+ resolvedSessionId = match;
48
+ }
49
+ const sessionStatus = await sessionManager.getSessionStatus(resolvedSessionId);
50
+ if (!sessionStatus) {
51
+ throw new Error(`Session not found: ${sessionId}`);
52
+ }
53
+ const shortId = resolvedSessionId.slice(0, 8);
54
+ const handleCompleted = async () => {
55
+ const answers = await sessionManager.getSessionAnswers(resolvedSessionId);
56
+ const request = await sessionManager.getSessionRequest(resolvedSessionId);
57
+ if (!answers || !request) {
58
+ throw new Error(`Session data incomplete: ${resolvedSessionId}`);
59
+ }
60
+ const formatted = ResponseFormatter.formatUserResponse(answers, request.questions);
61
+ const count = request.questions.length;
62
+ const header = `[Session: ${shortId} | Questions: ${count}]`;
63
+ const formattedResponse = `${header}\n\n${formatted}`;
64
+ await sessionManager.markSessionAsRead(resolvedSessionId);
65
+ return { formattedResponse, sessionId: resolvedSessionId, status: "completed" };
66
+ };
67
+ switch (sessionStatus.status) {
68
+ case "completed": {
69
+ return handleCompleted();
70
+ }
71
+ case "pending":
72
+ case "in-progress": {
73
+ if (blocking) {
74
+ await sessionManager.waitForAnswers(resolvedSessionId, 0, undefined, signal);
75
+ return handleCompleted();
76
+ }
77
+ const pendingResponse = `[Session: ${shortId} | Status: pending]\n\nNo answers yet.`;
78
+ return { formattedResponse: pendingResponse, sessionId: resolvedSessionId, status: "pending" };
79
+ }
80
+ case "rejected": {
81
+ const reason = sessionStatus.rejectionReason;
82
+ const rejectedResponse = `[Session: ${shortId} | Status: rejected]\n\nUser rejected this question set.${reason ? ` Reason: "${reason}"` : ""}`;
83
+ return { formattedResponse: rejectedResponse, sessionId: resolvedSessionId, status: "rejected" };
84
+ }
85
+ default: {
86
+ const defaultResponse = `[Session: ${shortId} | Status: ${sessionStatus.status}]\n\nSession is no longer active.`;
87
+ return { formattedResponse: defaultResponse, sessionId: resolvedSessionId, status: sessionStatus.status };
88
+ }
89
+ }
90
+ };
30
91
  return {
31
92
  ask,
93
+ askNonBlocking,
94
+ getAnsweredQuestions,
32
95
  cleanupExpiredSessions: () => sessionManager.cleanupExpiredSessions(),
33
96
  ensureInitialized,
34
97
  markAbandoned: (sessionId) => sessionManager.updateSessionStatus(sessionId, "abandoned"),
@@ -48,10 +48,10 @@ export const en = {
48
48
  customAnswerLabel: "Custom answer",
49
49
  customAnswerHint: "(Tab to submit)",
50
50
  otherCustom: "Other (custom)",
51
- placeholder: "Type your answer (Enter = newline, Tab = done)",
51
+ placeholder: "Type your answer...",
52
52
  singleLinePlaceholder: "Type here...",
53
53
  multiLinePlaceholder: "Type your answer...",
54
- elaboratePlaceholder: "Tell the AI what you need (Enter/Tab = Skip)",
54
+ elaboratePlaceholder: "Tell the AI what you need...",
55
55
  },
56
56
  question: {
57
57
  multipleChoice: "Multiple Choice",
@@ -1,7 +1,7 @@
1
1
  import { FastMCP } from "fastmcp";
2
2
  import { randomUUID } from "crypto";
3
3
  import { AskUserQuestionsParametersSchema, createAskUserQuestionsCore, } from "./core/ask-user-questions.js";
4
- import { TOOL_DESCRIPTION } from "./shared/schemas.js";
4
+ import { GetAnsweredQuestionsArgsSchema, GET_ANSWERED_QUESTIONS_DESCRIPTION, TOOL_DESCRIPTION } from "./shared/schemas.js";
5
5
  const askUserQuestionsCore = createAskUserQuestionsCore();
6
6
  // Track active requests with their AbortControllers for disconnect handling
7
7
  const activeRequests = new Map();
@@ -52,6 +52,17 @@ server.addTool({
52
52
  const workingDirectory = ctx
53
53
  .workingDirectory;
54
54
  try {
55
+ // Handle non-blocking mode
56
+ if (args.nonBlocking) {
57
+ const { sessionId, questionCount } = await askUserQuestionsCore.askNonBlocking(args.questions, callId, workingDirectory);
58
+ const shortId = sessionId.slice(0, 8);
59
+ const responseText = `[Session: ${shortId} | Questions: ${questionCount} | Status: pending]\n\n` +
60
+ `Questions submitted successfully.\n` +
61
+ `Use get_answered_questions(session_id="${shortId}") or \`auq fetch-answers ${shortId}\` to retrieve answers.`;
62
+ return {
63
+ content: [{ text: responseText, type: "text" }],
64
+ };
65
+ }
55
66
  const { formattedResponse, sessionId } = await askUserQuestionsCore.ask(args.questions, callId, workingDirectory, controller.signal);
56
67
  // Update entry with sessionId for disconnect handler
57
68
  const entry = activeRequests.get(callId);
@@ -59,11 +70,16 @@ server.addTool({
59
70
  entry.sessionId = sessionId;
60
71
  }
61
72
  log.info("Session completed successfully", { sessionId, callId });
73
+ // Prepend metadata header to blocking responses
74
+ const shortId = sessionId.slice(0, 8);
75
+ const count = args.questions.length;
76
+ const header = `[Session: ${shortId} | Questions: ${count}]`;
77
+ const responseWithHeader = `${header}\n\n${formattedResponse}`;
62
78
  // Return formatted response to AI model
63
79
  return {
64
80
  content: [
65
81
  {
66
- text: formattedResponse,
82
+ text: responseWithHeader,
67
83
  type: "text",
68
84
  },
69
85
  ],
@@ -102,6 +118,47 @@ server.addTool({
102
118
  },
103
119
  parameters: AskUserQuestionsParametersSchema,
104
120
  });
121
+ // Add the get_answered_questions tool
122
+ server.addTool({
123
+ name: "get_answered_questions",
124
+ annotations: {
125
+ title: "Get Answered Questions",
126
+ openWorldHint: false,
127
+ readOnlyHint: true,
128
+ idempotentHint: true,
129
+ },
130
+ description: GET_ANSWERED_QUESTIONS_DESCRIPTION,
131
+ parameters: GetAnsweredQuestionsArgsSchema,
132
+ execute: async (args, ctx) => {
133
+ const { log } = ctx;
134
+ const callId = randomUUID();
135
+ const controller = new AbortController();
136
+ activeRequests.set(callId, { controller });
137
+ try {
138
+ await askUserQuestionsCore.ensureInitialized();
139
+ const { formattedResponse, sessionId, status } = await askUserQuestionsCore.getAnsweredQuestions(args.session_id, args.blocking, controller.signal);
140
+ log.info("Fetched answered questions", { sessionId, status, callId });
141
+ return {
142
+ content: [{ text: formattedResponse, type: "text" }],
143
+ };
144
+ }
145
+ catch (error) {
146
+ if (error instanceof Error && error.message === "ABORTED") {
147
+ log.warn("Fetch aborted: AI client disconnected", { callId });
148
+ return {
149
+ content: [{ text: "Fetch aborted: AI client disconnected", type: "text" }],
150
+ };
151
+ }
152
+ log.error("Fetch answered questions failed", { error: String(error) });
153
+ return {
154
+ content: [{ text: `Error fetching answers: ${error}`, type: "text" }],
155
+ };
156
+ }
157
+ finally {
158
+ activeRequests.delete(callId);
159
+ }
160
+ },
161
+ });
105
162
  // Handle AI client disconnections gracefully
106
163
  // Note: FastMCP disconnect event support depends on the version.
107
164
  // If the event is not available, stale detection handles orphaned sessions as fallback.