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
@@ -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.
@@ -22,7 +22,7 @@ export class ResponseFormatter {
22
22
  * @param questions - Original questions asked to the user
23
23
  * @returns Formatted text response ready to send to AI model
24
24
  */
25
- static formatUserResponse(answers, questions) {
25
+ static formatUserResponse(answers, questions, sessionId) {
26
26
  // Validate that we have matching questions and answers
27
27
  if (answers.answers.length === 0) {
28
28
  throw new Error("No answers provided in session");
@@ -31,7 +31,12 @@ export class ResponseFormatter {
31
31
  throw new Error("No questions provided");
32
32
  }
33
33
  // Start with header
34
- const lines = ["Here are the user's answers:", ""];
34
+ const lines = [];
35
+ if (sessionId) {
36
+ const shortId = sessionId.slice(0, 8);
37
+ lines.push(`[Session: ${shortId} | Questions: ${questions.length}]`, "");
38
+ }
39
+ lines.push("Here are the user's answers:", "");
35
40
  // Format each question and its answer(s)
36
41
  // Note: A question can have multiple answers (e.g., regular answer + elaboration request)
37
42
  const formattedQuestions = [];
@@ -136,6 +141,78 @@ export class ResponseFormatter {
136
141
  static formatRephraseRequest(questionIndex, title) {
137
142
  return `[REPHRASE_REQUEST] Please rephrase question '${title}' in a different way\nQuestion index: ${questionIndex}`;
138
143
  }
144
+ /**
145
+ * Get the short form of a session ID (first 8 characters)
146
+ *
147
+ * @param sessionId - Full session ID
148
+ * @returns First 8 characters of the session ID
149
+ */
150
+ static getShortId(sessionId) {
151
+ return sessionId.slice(0, 8);
152
+ }
153
+ /**
154
+ * Format a non-blocking submission confirmation message
155
+ *
156
+ * @param sessionId - Full session ID
157
+ * @param questionCount - Number of questions submitted
158
+ * @returns Formatted confirmation string
159
+ */
160
+ static formatNonBlockingSubmission(sessionId, questionCount) {
161
+ const shortId = sessionId.slice(0, 8);
162
+ return [
163
+ `[Session: ${shortId} | Questions: ${questionCount} | Status: pending]`,
164
+ "",
165
+ "Questions submitted successfully.",
166
+ `Use get_answered_questions(session_id="${shortId}") or \`auq fetch-answers ${shortId}\` to retrieve answers.`,
167
+ ].join("\n");
168
+ }
169
+ /**
170
+ * Format a pending status message for a session
171
+ *
172
+ * @param sessionId - Full session ID
173
+ * @param remainingTime - Optional remaining time string (e.g., "4m 45s")
174
+ * @returns Formatted pending status string
175
+ */
176
+ static formatPendingStatus(sessionId, remainingTime) {
177
+ const shortId = sessionId.slice(0, 8);
178
+ const header = remainingTime
179
+ ? `[Session: ${shortId} | Status: pending | Remaining: ${remainingTime}]`
180
+ : `[Session: ${shortId} | Status: pending]`;
181
+ return [header, "", "No answers yet."].join("\n");
182
+ }
183
+ /**
184
+ * Format a rejected status message for a session
185
+ *
186
+ * @param sessionId - Full session ID
187
+ * @param reason - Optional rejection reason
188
+ * @returns Formatted rejected status string
189
+ */
190
+ static formatRejectedStatus(sessionId, reason) {
191
+ const shortId = sessionId.slice(0, 8);
192
+ const lines = [`[Session: ${shortId} | Status: rejected]`, ""];
193
+ if (reason) {
194
+ lines.push(`User rejected this question set. Reason: "${reason}"`);
195
+ }
196
+ else {
197
+ lines.push("User rejected this question set.");
198
+ }
199
+ return lines.join("\n");
200
+ }
201
+ /**
202
+ * Format a generic session status message (fallback for other statuses)
203
+ *
204
+ * @param sessionId - Full session ID
205
+ * @param status - Session status string (e.g., "abandoned", "timed_out")
206
+ * @returns Formatted status string
207
+ */
208
+ static formatSessionStatus(sessionId, status) {
209
+ const shortId = sessionId.slice(0, 8);
210
+ return [
211
+ `[Session: ${shortId} | Status: ${status}]`,
212
+ "",
213
+ `Session status: ${status}`,
214
+ ].join("\n");
215
+ }
139
216
  /**
140
217
  * Format a single question and its answer(s)
141
218
  *
@@ -164,6 +164,42 @@ export class SessionManager {
164
164
  }
165
165
  return pendingSessions;
166
166
  }
167
+ /**
168
+ * Get all unread sessions (completed sessions where lastReadAt is not set)
169
+ */
170
+ async getUnreadSessions() {
171
+ const sessionIds = await this.getAllSessionIds();
172
+ const unreadSessions = [];
173
+ for (const sessionId of sessionIds) {
174
+ try {
175
+ const status = await this.getSessionStatus(sessionId);
176
+ if (!status || status.status !== "completed")
177
+ continue;
178
+ const answers = await this.getSessionAnswers(sessionId);
179
+ if (!answers || answers.lastReadAt)
180
+ continue;
181
+ unreadSessions.push({ sessionId, createdAt: status.createdAt });
182
+ }
183
+ catch {
184
+ continue;
185
+ }
186
+ }
187
+ // Sort by createdAt descending (newest first)
188
+ unreadSessions.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
189
+ return unreadSessions.map((s) => s.sessionId);
190
+ }
191
+ /**
192
+ * Mark a session as read by updating lastReadAt in answers.json
193
+ */
194
+ async markSessionAsRead(sessionId) {
195
+ const answers = await this.readSessionFile(sessionId, SESSION_FILES.ANSWERS);
196
+ if (!answers) {
197
+ throw new Error(`Session ${sessionId} has no answers to mark as read`);
198
+ }
199
+ answers.lastReadAt = new Date().toISOString();
200
+ await this.writeSessionFile(sessionId, SESSION_FILES.ANSWERS, answers);
201
+ return answers;
202
+ }
167
203
  /**
168
204
  * Get session count
169
205
  */
@@ -924,4 +924,90 @@ describe("ResponseFormatter", () => {
924
924
  "Question index: 0");
925
925
  });
926
926
  });
927
+ describe("metadata header", () => {
928
+ const validQuestions = [
929
+ {
930
+ options: [
931
+ { description: "Type-safe JavaScript", label: "TypeScript" },
932
+ { description: "Dynamic web language", label: "JavaScript" },
933
+ ],
934
+ prompt: "Which language do you prefer?",
935
+ title: "Language",
936
+ },
937
+ ];
938
+ const validAnswers = {
939
+ answers: [
940
+ {
941
+ questionIndex: 0,
942
+ selectedOption: "TypeScript",
943
+ timestamp: "2025-01-01T00:00:00Z",
944
+ },
945
+ ],
946
+ sessionId: "a3f2e8b1-1234-4567-89ab-cdef01234567",
947
+ timestamp: "2025-01-01T00:00:00Z",
948
+ };
949
+ it("should prepend metadata header when sessionId is provided", () => {
950
+ const result = ResponseFormatter.formatUserResponse(validAnswers, validQuestions, "a3f2e8b1-1234-4567-89ab-cdef01234567");
951
+ expect(result).toMatch(/^\[Session: a3f2e8b1 \| Questions: \d+\]/);
952
+ expect(result).toContain("Here are the user's answers:");
953
+ });
954
+ it("should not include header when sessionId is omitted", () => {
955
+ const result = ResponseFormatter.formatUserResponse(validAnswers, validQuestions);
956
+ expect(result).not.toContain("[Session:");
957
+ expect(result.startsWith("Here are the user's answers:")).toBe(true);
958
+ });
959
+ it("should use short ID (first 8 chars)", () => {
960
+ const result = ResponseFormatter.formatUserResponse(validAnswers, validQuestions, "abcdef12-3456-4789-abcd-ef0123456789");
961
+ expect(result).toContain("[Session: abcdef12");
962
+ expect(result).not.toContain("abcdef12-3456");
963
+ });
964
+ });
965
+ describe("formatNonBlockingSubmission", () => {
966
+ it("should format non-blocking submission message", () => {
967
+ const result = ResponseFormatter.formatNonBlockingSubmission("a3f2e8b1-1234-4567-89ab-cdef01234567", 3);
968
+ expect(result).toContain("[Session: a3f2e8b1 | Questions: 3 | Status: pending]");
969
+ expect(result).toContain("Questions submitted successfully.");
970
+ expect(result).toContain("get_answered_questions");
971
+ expect(result).toContain("auq fetch-answers");
972
+ });
973
+ });
974
+ describe("formatPendingStatus", () => {
975
+ it("should format pending status without remaining time", () => {
976
+ const result = ResponseFormatter.formatPendingStatus("a3f2e8b1-1234-4567-89ab-cdef01234567");
977
+ expect(result).toContain("[Session: a3f2e8b1 | Status: pending]");
978
+ expect(result).toContain("No answers yet.");
979
+ });
980
+ it("should include remaining time when provided", () => {
981
+ const result = ResponseFormatter.formatPendingStatus("a3f2e8b1-1234-4567-89ab-cdef01234567", "4m 45s");
982
+ expect(result).toContain("Remaining: 4m 45s");
983
+ });
984
+ });
985
+ describe("formatRejectedStatus", () => {
986
+ it("should format rejected with reason", () => {
987
+ const result = ResponseFormatter.formatRejectedStatus("a3f2e8b1-1234-4567-89ab-cdef01234567", "not applicable");
988
+ expect(result).toContain("[Session: a3f2e8b1 | Status: rejected]");
989
+ expect(result).toContain('Reason: "not applicable"');
990
+ });
991
+ it("should format rejected without reason", () => {
992
+ const result = ResponseFormatter.formatRejectedStatus("a3f2e8b1-1234-4567-89ab-cdef01234567");
993
+ expect(result).toContain("[Session: a3f2e8b1 | Status: rejected]");
994
+ expect(result).toContain("User rejected this question set.");
995
+ expect(result).not.toContain("Reason:");
996
+ });
997
+ it("should handle null reason", () => {
998
+ const result = ResponseFormatter.formatRejectedStatus("a3f2e8b1-1234-4567-89ab-cdef01234567", null);
999
+ expect(result).not.toContain("Reason:");
1000
+ });
1001
+ });
1002
+ describe("formatSessionStatus", () => {
1003
+ it("should format generic session status", () => {
1004
+ const result = ResponseFormatter.formatSessionStatus("a3f2e8b1-1234-4567-89ab-cdef01234567", "abandoned");
1005
+ expect(result).toContain("[Session: a3f2e8b1 | Status: abandoned]");
1006
+ });
1007
+ });
1008
+ describe("getShortId", () => {
1009
+ it("should return first 8 characters", () => {
1010
+ expect(ResponseFormatter.getShortId("a3f2e8b1-1234-4567-89ab-cdef01234567")).toBe("a3f2e8b1");
1011
+ });
1012
+ });
927
1013
  });
@@ -655,4 +655,133 @@ describe("SessionManager", () => {
655
655
  expect(pending).toEqual([]);
656
656
  });
657
657
  });
658
+ describe("read tracking", () => {
659
+ const testQuestions = [
660
+ {
661
+ options: [
662
+ { description: "Dynamic web language", label: "JavaScript" },
663
+ { description: "Typed superset of JavaScript", label: "TypeScript" },
664
+ ],
665
+ prompt: "Which programming language do you prefer?",
666
+ title: "Language",
667
+ },
668
+ ];
669
+ it("should mark session as read and persist lastReadAt", async () => {
670
+ const sessionId = await sessionManager.createSession(testQuestions);
671
+ await sessionManager.saveSessionAnswers(sessionId, {
672
+ sessionId,
673
+ timestamp: new Date().toISOString(),
674
+ answers: [
675
+ {
676
+ questionIndex: 0,
677
+ timestamp: new Date().toISOString(),
678
+ selectedOption: testQuestions[0].options[0].label,
679
+ },
680
+ ],
681
+ });
682
+ const result = await sessionManager.markSessionAsRead(sessionId);
683
+ expect(result.lastReadAt).toBeDefined();
684
+ expect(new Date(result.lastReadAt).getTime()).toBeGreaterThan(0);
685
+ // Verify persistence
686
+ const answers = await sessionManager.getSessionAnswers(sessionId);
687
+ expect(answers?.lastReadAt).toBe(result.lastReadAt);
688
+ });
689
+ it("should update lastReadAt on repeated calls", async () => {
690
+ const sessionId = await sessionManager.createSession(testQuestions);
691
+ await sessionManager.saveSessionAnswers(sessionId, {
692
+ sessionId,
693
+ timestamp: new Date().toISOString(),
694
+ answers: [
695
+ {
696
+ questionIndex: 0,
697
+ timestamp: new Date().toISOString(),
698
+ selectedOption: testQuestions[0].options[0].label,
699
+ },
700
+ ],
701
+ });
702
+ const first = await sessionManager.markSessionAsRead(sessionId);
703
+ await new Promise((r) => setTimeout(r, 10));
704
+ const second = await sessionManager.markSessionAsRead(sessionId);
705
+ expect(second.lastReadAt).toBeDefined();
706
+ expect(first.lastReadAt).not.toBe(second.lastReadAt);
707
+ });
708
+ it("should throw when marking session without answers", async () => {
709
+ const sessionId = await sessionManager.createSession(testQuestions);
710
+ await expect(sessionManager.markSessionAsRead(sessionId)).rejects.toThrow("answers.json");
711
+ });
712
+ });
713
+ describe("getUnreadSessions", () => {
714
+ const testQuestions = [
715
+ {
716
+ options: [
717
+ { description: "Dynamic web language", label: "JavaScript" },
718
+ { description: "Typed superset of JavaScript", label: "TypeScript" },
719
+ ],
720
+ prompt: "Which programming language do you prefer?",
721
+ title: "Language",
722
+ },
723
+ ];
724
+ async function createCompletedSession(manager) {
725
+ const sessionId = await manager.createSession(testQuestions);
726
+ await manager.saveSessionAnswers(sessionId, {
727
+ sessionId,
728
+ timestamp: new Date().toISOString(),
729
+ answers: [
730
+ {
731
+ questionIndex: 0,
732
+ timestamp: new Date().toISOString(),
733
+ selectedOption: testQuestions[0].options[0].label,
734
+ },
735
+ ],
736
+ });
737
+ await manager.updateSessionStatus(sessionId, "completed");
738
+ return sessionId;
739
+ }
740
+ it("should return completed unread sessions", async () => {
741
+ const sessionId = await createCompletedSession(sessionManager);
742
+ const unread = await sessionManager.getUnreadSessions();
743
+ expect(unread).toContain(sessionId);
744
+ });
745
+ it("should exclude read sessions", async () => {
746
+ const sessionId = await createCompletedSession(sessionManager);
747
+ await sessionManager.markSessionAsRead(sessionId);
748
+ const unread = await sessionManager.getUnreadSessions();
749
+ expect(unread).not.toContain(sessionId);
750
+ });
751
+ it("should exclude pending sessions", async () => {
752
+ const sessionId = await sessionManager.createSession(testQuestions);
753
+ const unread = await sessionManager.getUnreadSessions();
754
+ expect(unread).not.toContain(sessionId);
755
+ });
756
+ it("should exclude rejected sessions", async () => {
757
+ const sessionId = await sessionManager.createSession(testQuestions);
758
+ await sessionManager.rejectSession(sessionId, "test");
759
+ const unread = await sessionManager.getUnreadSessions();
760
+ expect(unread).not.toContain(sessionId);
761
+ });
762
+ it("should sort newest first", async () => {
763
+ const id1 = await createCompletedSession(sessionManager);
764
+ await new Promise((r) => setTimeout(r, 10));
765
+ const id2 = await createCompletedSession(sessionManager);
766
+ const unread = await sessionManager.getUnreadSessions();
767
+ expect(unread[0]).toBe(id2); // newest first
768
+ expect(unread[1]).toBe(id1);
769
+ });
770
+ it("should not mark as read when saving answers", async () => {
771
+ const sessionId = await sessionManager.createSession(testQuestions);
772
+ await sessionManager.saveSessionAnswers(sessionId, {
773
+ sessionId,
774
+ timestamp: new Date().toISOString(),
775
+ answers: [
776
+ {
777
+ questionIndex: 0,
778
+ timestamp: new Date().toISOString(),
779
+ selectedOption: testQuestions[0].options[0].label,
780
+ },
781
+ ],
782
+ });
783
+ const answers = await sessionManager.getSessionAnswers(sessionId);
784
+ expect(answers?.lastReadAt).toBeUndefined();
785
+ });
786
+ });
658
787
  });
@@ -85,6 +85,7 @@ export function createAskUserQuestionsParametersSchema(maxQuestions = DEFAULT_LI
85
85
  questions: questionsSchema.describe(`Questions to ask the user (1-${maxQuestions} questions). ` +
86
86
  `Each question must include: prompt (full question text), title (short label, max 12 chars), ` +
87
87
  `options (2-${maxOptions} choices with labels and descriptions), and multiSelect (boolean).`),
88
+ nonBlocking: z.boolean().default(false).describe("Set to true to submit questions without waiting for answers. The tool will return immediately with a session ID that can be used with get_answered_questions to fetch answers later. Default: false (blocking mode)."),
88
89
  });
89
90
  }
90
91
  // Default schemas for backward compatibility (using DEFAULT_LIMITS)
@@ -106,3 +107,10 @@ export const TOOL_DESCRIPTION = "Use this tool when you need to ask the user que
106
107
  'Recommend an option unless absolutely necessary, make it the first option in the list and add "(Recommended)" at the end of the label\n' +
107
108
  'For multiSelect questions, you MAY mark multiple options as "(Recommended)" if several choices are advisable\n' +
108
109
  'Do NOT use this tool to ask "Is my plan ready?" or "Should I proceed?"';
110
+ export const GetAnsweredQuestionsArgsSchema = z.object({
111
+ session_id: z.string().describe("The session ID returned from a non-blocking ask_user_questions call. Accepts both full UUID and short (first 8 chars) ID."),
112
+ blocking: z.boolean().default(false).describe("If true, wait until the user answers before returning. If false (default), return immediately with current status."),
113
+ });
114
+ export const GET_ANSWERED_QUESTIONS_DESCRIPTION = "Fetch answers for a previously submitted non-blocking question set. " +
115
+ "Use this after calling ask_user_questions with nonBlocking: true. " +
116
+ "Returns the user's answers if available, or current session status if still pending.";
@@ -1,9 +1,9 @@
1
1
  import React, { useState, useCallback, useMemo, useEffect } from "react";
2
2
  import { useInput } from "ink";
3
3
  import { ThemeContext } from "./ThemeContext.js";
4
- import { getTheme, listThemes, darkTheme, hasTheme } from "./themes/index.js";
5
- import { detectSystemTheme } from "./utils/detectTheme.js";
6
- import { getSavedTheme, saveTheme } from "./utils/config.js";
4
+ import { getTheme, listThemes, darkTheme, hasTheme } from "./shared/themes/index.js";
5
+ import { detectSystemTheme } from "./shared/utils/detectTheme.js";
6
+ import { getSavedTheme, saveTheme } from "./shared/utils/config.js";
7
7
  import { KEYS } from "./constants/keybindings.js";
8
8
  function resolveTheme(mode) {
9
9
  if (mode === "system") {
@@ -8,7 +8,7 @@ import { UpdateBadge } from "./UpdateBadge.js";
8
8
  * Header component - displays app logo and status
9
9
  * Shows at the top of the TUI with gradient branding and live-updating pending queue count
10
10
  */
11
- export const Header = ({ pendingCount, updateInfo, onUpdateBadgeActivate }) => {
11
+ export const Header = ({ pendingCount, isCheckingUpdate, updateInfo, onUpdateBadgeActivate }) => {
12
12
  const { theme } = useTheme();
13
13
  const [flash, setFlash] = useState(false);
14
14
  const [prevCount, setPrevCount] = useState(pendingCount);
@@ -38,6 +38,7 @@ export const Header = ({ pendingCount, updateInfo, onUpdateBadgeActivate }) => {
38
38
  version),
39
39
  updateInfo && (React.createElement(UpdateBadge, { updateType: updateInfo.updateType, latestVersion: updateInfo.latestVersion })),
40
40
  React.createElement(Text, { dimColor: true }, " "),
41
+ isCheckingUpdate && (React.createElement(Text, { dimColor: true }, " checking... ")),
41
42
  React.createElement(Text, { backgroundColor: theme.components.header.pillBg, bold: flash, color: flash
42
43
  ? theme.components.header.queueFlash
43
44
  : pendingCount > 0
@@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react";
3
3
  import { t } from "../../i18n/index.js";
4
4
  import { useConfig } from "../ConfigContext.js";
5
5
  import { useTheme } from "../ThemeContext.js";
6
- import { isRecommendedOption } from "../utils/recommended.js";
6
+ import { isRecommendedOption } from "../shared/utils/recommended.js";
7
7
  import { fitToVisualWidth, getVisualWidth, padToVisualWidth, } from "../utils/visualWidth.js";
8
8
  import { MultiLineTextInput } from "./MultiLineTextInput.js";
9
9
  // isRecommendedOption is imported from ../utils/recommended.js
@@ -1,7 +1,7 @@
1
1
  import { Box, Text, useInput, useStdout } from "ink";
2
2
  import React, { useEffect, useState } from "react";
3
3
  import { useTheme } from "../ThemeContext.js";
4
- import { formatRelativeTime } from "../utils/relativeTime.js";
4
+ import { formatRelativeTime } from "../shared/utils/relativeTime.js";
5
5
  import { KEYS } from "../constants/keybindings.js";
6
6
  /* ------------------------------------------------------------------ */
7
7
  /* Helpers */
@@ -6,7 +6,7 @@ import { SessionManager } from "../../session/SessionManager.js";
6
6
  import { getSessionDirectory } from "../../session/utils.js";
7
7
  import { useTheme } from "../ThemeContext.js";
8
8
  import { useConfig } from "../ConfigContext.js";
9
- import { isRecommendedOption } from "../utils/recommended.js";
9
+ import { isRecommendedOption } from "../shared/utils/recommended.js";
10
10
  import { KEYS } from "../constants/keybindings.js";
11
11
  import { ConfirmationDialog } from "./ConfirmationDialog.js";
12
12
  import { QuestionDisplay } from "./QuestionDisplay.js";
@@ -17,7 +17,7 @@ vi.mock("ink", async () => {
17
17
  };
18
18
  });
19
19
  import { ThemeContext } from "../../ThemeContext.js";
20
- import { darkTheme } from "../../themes/dark.js";
20
+ import { darkTheme } from "../../shared/themes/dark.js";
21
21
  import { ConfirmationDialog } from "../ConfirmationDialog.js";
22
22
  const mockThemeValue = {
23
23
  theme: darkTheme,
@@ -2,7 +2,7 @@ import React from "react";
2
2
  import { cleanup, render } from "ink-testing-library";
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
4
  import { ThemeContext } from "../../ThemeContext.js";
5
- import { darkTheme } from "../../themes/dark.js";
5
+ import { darkTheme } from "../../shared/themes/dark.js";
6
6
  import { KEY_LABELS } from "../../constants/keybindings.js";
7
7
  import { Footer } from "../Footer.js";
8
8
  const mockThemeValue = {