wiggum-cli 0.7.3 → 0.7.5

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 (35) hide show
  1. package/dist/ai/conversation/conversation-manager.d.ts +31 -1
  2. package/dist/ai/conversation/conversation-manager.d.ts.map +1 -1
  3. package/dist/ai/conversation/conversation-manager.js +48 -3
  4. package/dist/ai/conversation/conversation-manager.js.map +1 -1
  5. package/dist/ai/conversation/index.d.ts +3 -2
  6. package/dist/ai/conversation/index.d.ts.map +1 -1
  7. package/dist/ai/conversation/index.js +1 -0
  8. package/dist/ai/conversation/index.js.map +1 -1
  9. package/dist/ai/conversation/interview-tools.d.ts +85 -0
  10. package/dist/ai/conversation/interview-tools.d.ts.map +1 -0
  11. package/dist/ai/conversation/interview-tools.js +255 -0
  12. package/dist/ai/conversation/interview-tools.js.map +1 -0
  13. package/dist/ai/conversation/spec-generator.d.ts +36 -0
  14. package/dist/ai/conversation/spec-generator.d.ts.map +1 -1
  15. package/dist/ai/conversation/spec-generator.js +219 -30
  16. package/dist/ai/conversation/spec-generator.js.map +1 -1
  17. package/dist/commands/init.d.ts.map +1 -1
  18. package/dist/commands/init.js +10 -20
  19. package/dist/commands/init.js.map +1 -1
  20. package/dist/utils/repl-prompts.d.ts +58 -0
  21. package/dist/utils/repl-prompts.d.ts.map +1 -0
  22. package/dist/utils/repl-prompts.js +231 -0
  23. package/dist/utils/repl-prompts.js.map +1 -0
  24. package/dist/utils/tui.d.ts +61 -0
  25. package/dist/utils/tui.d.ts.map +1 -0
  26. package/dist/utils/tui.js +214 -0
  27. package/dist/utils/tui.js.map +1 -0
  28. package/package.json +1 -1
  29. package/src/ai/conversation/conversation-manager.ts +66 -4
  30. package/src/ai/conversation/index.ts +7 -0
  31. package/src/ai/conversation/interview-tools.ts +293 -0
  32. package/src/ai/conversation/spec-generator.ts +287 -34
  33. package/src/commands/init.ts +10 -22
  34. package/src/utils/repl-prompts.ts +286 -0
  35. package/src/utils/tui.ts +262 -0
@@ -0,0 +1,286 @@
1
+ /**
2
+ * REPL-Friendly Prompts
3
+ * Simple prompts using readline that work properly in REPL context
4
+ * (Clack prompts conflict with REPL readline management)
5
+ */
6
+
7
+ import readline from 'node:readline';
8
+ import pc from 'picocolors';
9
+ import { simpson } from './colors.js';
10
+
11
+ export interface SelectOption<T> {
12
+ value: T;
13
+ label: string;
14
+ hint?: string;
15
+ }
16
+
17
+ /**
18
+ * Simple select prompt for REPL
19
+ */
20
+ export async function select<T>(options: {
21
+ message: string;
22
+ options: SelectOption<T>[];
23
+ }): Promise<T | null> {
24
+ const { message, options: choices } = options;
25
+
26
+ console.log('');
27
+ console.log(`${simpson.yellow('?')} ${message}`);
28
+ console.log('');
29
+
30
+ choices.forEach((choice, index) => {
31
+ const hint = choice.hint ? pc.dim(` (${choice.hint})`) : '';
32
+ console.log(` ${pc.cyan(`${index + 1})`)} ${choice.label}${hint}`);
33
+ });
34
+
35
+ console.log('');
36
+
37
+ const rl = readline.createInterface({
38
+ input: process.stdin,
39
+ output: process.stdout,
40
+ });
41
+
42
+ return new Promise((resolve) => {
43
+ rl.question(`${pc.dim('Enter number (1-' + choices.length + '):')} `, (answer) => {
44
+ rl.close();
45
+ const num = parseInt(answer.trim(), 10);
46
+ if (num >= 1 && num <= choices.length) {
47
+ const selected = choices[num - 1];
48
+ console.log(`${pc.green('✓')} Selected: ${selected.label}`);
49
+ resolve(selected.value);
50
+ } else {
51
+ console.log(pc.red('Invalid selection'));
52
+ resolve(null);
53
+ }
54
+ });
55
+
56
+ rl.on('SIGINT', () => {
57
+ rl.close();
58
+ resolve(null);
59
+ });
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Simple password prompt for REPL
65
+ * Masks input with asterisks
66
+ */
67
+ export async function password(options: {
68
+ message: string;
69
+ }): Promise<string | null> {
70
+ const { message } = options;
71
+
72
+ console.log('');
73
+ console.log(`${simpson.yellow('?')} ${message}`);
74
+
75
+ return new Promise((resolve) => {
76
+ let input = '';
77
+
78
+ // Show initial cursor position
79
+ process.stdout.write(`${pc.dim('>')} `);
80
+
81
+ const onData = (char: string) => {
82
+ // Ctrl+C
83
+ if (char === '\u0003') {
84
+ cleanup();
85
+ console.log('');
86
+ resolve(null);
87
+ return;
88
+ }
89
+
90
+ // Enter
91
+ if (char === '\r' || char === '\n') {
92
+ cleanup();
93
+ // Clear the line and show fixed mask
94
+ process.stdout.write('\r' + pc.dim('>') + ' ' + '*'.repeat(32) + '\n');
95
+ console.log(`${pc.green('✓')} API key entered`);
96
+ resolve(input.trim() || null);
97
+ return;
98
+ }
99
+
100
+ // Backspace
101
+ if (char === '\u007F' || char === '\b') {
102
+ if (input.length > 0) {
103
+ input = input.slice(0, -1);
104
+ // Clear and rewrite asterisks
105
+ process.stdout.write('\r' + pc.dim('>') + ' ' + '*'.repeat(input.length) + ' \b');
106
+ }
107
+ return;
108
+ }
109
+
110
+ // Regular character
111
+ if (char.charCodeAt(0) >= 32 && char.charCodeAt(0) <= 126) {
112
+ input += char;
113
+ process.stdout.write('*');
114
+ }
115
+ };
116
+
117
+ const cleanup = () => {
118
+ if (process.stdin.isTTY) {
119
+ process.stdin.setRawMode(false);
120
+ }
121
+ process.stdin.removeListener('data', onData);
122
+ process.stdin.pause();
123
+ };
124
+
125
+ if (process.stdin.isTTY) {
126
+ process.stdin.setRawMode(true);
127
+ }
128
+ process.stdin.resume();
129
+ process.stdin.setEncoding('utf8');
130
+ process.stdin.on('data', onData);
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Simple confirm prompt for REPL
136
+ */
137
+ export async function confirm(options: {
138
+ message: string;
139
+ initialValue?: boolean;
140
+ }): Promise<boolean | null> {
141
+ const { message, initialValue = true } = options;
142
+
143
+ console.log('');
144
+ const defaultHint = initialValue ? 'Y/n' : 'y/N';
145
+
146
+ const rl = readline.createInterface({
147
+ input: process.stdin,
148
+ output: process.stdout,
149
+ });
150
+
151
+ return new Promise((resolve) => {
152
+ rl.question(`${simpson.yellow('?')} ${message} ${pc.dim(`(${defaultHint}):`)} `, (answer) => {
153
+ rl.close();
154
+ const trimmed = answer.trim().toLowerCase();
155
+
156
+ if (trimmed === '') {
157
+ console.log(`${pc.green('✓')} ${initialValue ? 'Yes' : 'No'}`);
158
+ resolve(initialValue);
159
+ } else if (trimmed === 'y' || trimmed === 'yes') {
160
+ console.log(`${pc.green('✓')} Yes`);
161
+ resolve(true);
162
+ } else if (trimmed === 'n' || trimmed === 'no') {
163
+ console.log(`${pc.green('✓')} No`);
164
+ resolve(false);
165
+ } else {
166
+ console.log(pc.red('Invalid input, using default'));
167
+ resolve(initialValue);
168
+ }
169
+ });
170
+
171
+ rl.on('SIGINT', () => {
172
+ rl.close();
173
+ resolve(null);
174
+ });
175
+ });
176
+ }
177
+
178
+ /**
179
+ * Check if user cancelled (similar to clack's isCancel)
180
+ */
181
+ export function isCancel(value: unknown): value is null {
182
+ return value === null;
183
+ }
184
+
185
+ /**
186
+ * Multi-line input for paste support
187
+ * Reads input until an empty line (after content) or Ctrl+D
188
+ *
189
+ * @param prompt - The prompt to display
190
+ * @returns The collected input or null if cancelled
191
+ */
192
+ export async function multilineInput(options: {
193
+ prompt?: string;
194
+ /** Hint to show for how to end input */
195
+ endHint?: string;
196
+ }): Promise<string | null> {
197
+ const { prompt = '>', endHint = 'Press Enter twice when done' } = options;
198
+
199
+ console.log(pc.dim(` (${endHint})`));
200
+
201
+ return new Promise((resolve) => {
202
+ const lines: string[] = [];
203
+ let lastLineEmpty = false;
204
+
205
+ const rl = readline.createInterface({
206
+ input: process.stdin,
207
+ output: process.stdout,
208
+ prompt: `${pc.dim(prompt)} `,
209
+ });
210
+
211
+ rl.prompt();
212
+
213
+ rl.on('line', (line) => {
214
+ const trimmed = line.trim();
215
+
216
+ // If we get an empty line after having content, we're done
217
+ if (trimmed === '' && lines.length > 0 && lastLineEmpty) {
218
+ rl.close();
219
+ // Remove the trailing empty line we added
220
+ const result = lines.slice(0, -1).join('\n').trim();
221
+ if (result) {
222
+ console.log(pc.green('✓') + pc.dim(` Received ${result.split('\n').length} line(s)`));
223
+ }
224
+ resolve(result || null);
225
+ return;
226
+ }
227
+
228
+ lastLineEmpty = trimmed === '';
229
+ lines.push(line);
230
+ rl.prompt();
231
+ });
232
+
233
+ rl.on('close', () => {
234
+ // Ctrl+D or EOF
235
+ const result = lines.join('\n').trim();
236
+ if (result) {
237
+ console.log(pc.green('✓') + pc.dim(` Received ${result.split('\n').length} line(s)`));
238
+ }
239
+ resolve(result || null);
240
+ });
241
+
242
+ rl.on('SIGINT', () => {
243
+ rl.close();
244
+ console.log('');
245
+ resolve(null);
246
+ });
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Simple text input prompt
252
+ *
253
+ * @param prompt - The prompt to display
254
+ * @returns The input text or null if cancelled
255
+ */
256
+ export async function textInput(options: {
257
+ message: string;
258
+ placeholder?: string;
259
+ }): Promise<string | null> {
260
+ const { message, placeholder } = options;
261
+
262
+ const hint = placeholder ? pc.dim(` (${placeholder})`) : '';
263
+
264
+ const rl = readline.createInterface({
265
+ input: process.stdin,
266
+ output: process.stdout,
267
+ });
268
+
269
+ return new Promise((resolve) => {
270
+ rl.question(`${simpson.yellow('?')} ${message}${hint}: `, (answer) => {
271
+ rl.close();
272
+ const trimmed = answer.trim();
273
+ if (trimmed) {
274
+ resolve(trimmed);
275
+ } else {
276
+ resolve(null);
277
+ }
278
+ });
279
+
280
+ rl.on('SIGINT', () => {
281
+ rl.close();
282
+ console.log('');
283
+ resolve(null);
284
+ });
285
+ });
286
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * TUI (Text User Interface) Utilities
3
+ * Claude Code-like display helpers for the interview agent
4
+ */
5
+
6
+ import pc from 'picocolors';
7
+ import { simpson } from './colors.js';
8
+
9
+ /**
10
+ * Generation phases for the spec generator
11
+ */
12
+ export type Phase = 'context' | 'goals' | 'interview' | 'generation' | 'complete';
13
+
14
+ /**
15
+ * Phase display configuration
16
+ */
17
+ interface PhaseConfig {
18
+ label: string;
19
+ description: string;
20
+ }
21
+
22
+ const PHASE_CONFIG: Record<Phase, PhaseConfig> = {
23
+ context: { label: 'Context', description: 'Share reference URLs or files' },
24
+ goals: { label: 'Goals', description: 'Describe what you want to build' },
25
+ interview: { label: 'Interview', description: 'Answer clarifying questions' },
26
+ generation: { label: 'Spec', description: 'Generate specification' },
27
+ complete: { label: 'Complete', description: 'Spec generated' },
28
+ };
29
+
30
+ /**
31
+ * Tool icons for display
32
+ */
33
+ const TOOL_ICONS: Record<string, string> = {
34
+ read_file: '📂',
35
+ search_codebase: '🔍',
36
+ list_directory: '📁',
37
+ tavily_search: '🌐',
38
+ resolveLibraryId: '📚',
39
+ queryDocs: '📚',
40
+ context7: '📚',
41
+ default: '🔧',
42
+ };
43
+
44
+ /**
45
+ * Display the phase header (Claude Code style box)
46
+ */
47
+ export function displayPhaseHeader(
48
+ featureName: string,
49
+ currentPhase: Phase,
50
+ questionCount?: { current: number; max: number }
51
+ ): void {
52
+ const width = 66;
53
+
54
+ // Build phase indicators
55
+ const phases: Phase[] = ['context', 'goals', 'interview', 'generation'];
56
+ const phaseLabels = phases.map(phase => {
57
+ const config = PHASE_CONFIG[phase];
58
+ if (phase === currentPhase) {
59
+ return `[${config.label}]`;
60
+ }
61
+ return config.label;
62
+ });
63
+
64
+ const phaseString = phaseLabels.join(' → ');
65
+
66
+ // Build right-side info (question count)
67
+ const rightInfo = questionCount
68
+ ? `Questions: ${questionCount.current}/${questionCount.max}`
69
+ : '';
70
+
71
+ // Create the header box
72
+ const title = `Feature: ${featureName}`;
73
+ const topLine = '┌─' + ' '.repeat(width - 4) + '─┐';
74
+ const bottomLine = '└─' + ' '.repeat(width - 4) + '─┘';
75
+
76
+ // Title line
77
+ const titlePadding = width - 4 - title.length;
78
+ const titleLine = `│ ${simpson.yellow(title)}${' '.repeat(Math.max(0, titlePadding))} │`;
79
+
80
+ // Phase line with right info
81
+ const phasePadding = width - 4 - phaseString.length - rightInfo.length;
82
+ const phaseLine = `│ ${pc.dim(phaseString)}${' '.repeat(Math.max(0, phasePadding))}${pc.dim(rightInfo)} │`;
83
+
84
+ console.log('');
85
+ console.log(pc.dim(topLine));
86
+ console.log(titleLine);
87
+ console.log(phaseLine);
88
+ console.log(pc.dim(bottomLine));
89
+ console.log('');
90
+ }
91
+
92
+ /**
93
+ * Display a tool usage indicator (like Claude Code)
94
+ */
95
+ export function displayToolUse(toolName: string, args: Record<string, unknown>): void {
96
+ const icon = TOOL_ICONS[toolName] || TOOL_ICONS.default;
97
+ const formattedCall = formatToolCall(toolName, args);
98
+ console.log(pc.dim(` ${icon} ${formattedCall}`));
99
+ }
100
+
101
+ /**
102
+ * Format a tool call for display
103
+ */
104
+ function formatToolCall(toolName: string, args: Record<string, unknown>): string {
105
+ switch (toolName) {
106
+ case 'read_file':
107
+ return `Reading ${args.path}`;
108
+
109
+ case 'search_codebase':
110
+ const dir = args.directory ? ` in ${args.directory}/` : '';
111
+ return `Searching for "${args.pattern}"${dir}`;
112
+
113
+ case 'list_directory':
114
+ return `Listing ${args.path || '.'}`;
115
+
116
+ case 'tavily_search':
117
+ return `Web: "${args.query}"`;
118
+
119
+ case 'resolveLibraryId':
120
+ return `Looking up library: ${args.libraryName}`;
121
+
122
+ case 'queryDocs':
123
+ return `Docs: ${args.libraryId} - "${(args.query as string)?.slice(0, 40)}..."`;
124
+
125
+ default:
126
+ // Generic format
127
+ const argStr = Object.entries(args)
128
+ .slice(0, 2)
129
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`.slice(0, 30))
130
+ .join(', ');
131
+ return `${toolName}(${argStr})`;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Display session context (project info)
137
+ */
138
+ export function displaySessionContext(context: {
139
+ projectName?: string;
140
+ stack?: string;
141
+ entryPoints?: string[];
142
+ tools?: { tavily: boolean; context7: boolean; codebase: boolean };
143
+ }): void {
144
+ const width = 66;
145
+ const topLine = '┌─' + ' '.repeat(width - 4) + '─┐';
146
+ const bottomLine = '└─' + ' '.repeat(width - 4) + '─┘';
147
+
148
+ const lines: string[] = [];
149
+
150
+ if (context.projectName) {
151
+ lines.push(`Project: ${context.projectName}`);
152
+ }
153
+ if (context.stack) {
154
+ lines.push(`Stack: ${context.stack}`);
155
+ }
156
+ if (context.entryPoints && context.entryPoints.length > 0) {
157
+ lines.push(`Entry: ${context.entryPoints.slice(0, 2).join(', ')}`);
158
+ }
159
+ if (context.tools) {
160
+ const toolList = [];
161
+ if (context.tools.tavily) toolList.push('Tavily ✓');
162
+ if (context.tools.context7) toolList.push('Context7 ✓');
163
+ if (context.tools.codebase) toolList.push('Codebase ✓');
164
+ if (toolList.length > 0) {
165
+ lines.push(`Tools: ${toolList.join(' ')}`);
166
+ }
167
+ }
168
+
169
+ if (lines.length === 0) return;
170
+
171
+ console.log(pc.dim(topLine));
172
+ for (const line of lines) {
173
+ const padding = width - 4 - line.length;
174
+ console.log(`│ ${pc.dim(line)}${' '.repeat(Math.max(0, padding))} │`);
175
+ }
176
+ console.log(pc.dim(bottomLine));
177
+ console.log('');
178
+ }
179
+
180
+ /**
181
+ * Display progress phases with visual indicators
182
+ */
183
+ export function displayProgressPhases(currentPhase: Phase): void {
184
+ const phases: Phase[] = ['context', 'goals', 'interview', 'generation'];
185
+
186
+ console.log('');
187
+ for (const phase of phases) {
188
+ const config = PHASE_CONFIG[phase];
189
+ let indicator: string;
190
+ let style: (text: string) => string;
191
+
192
+ if (phase === currentPhase) {
193
+ indicator = '◐'; // In progress
194
+ style = simpson.yellow;
195
+ } else if (getPhaseIndex(phase) < getPhaseIndex(currentPhase)) {
196
+ indicator = '●'; // Completed
197
+ style = pc.green;
198
+ } else {
199
+ indicator = '○'; // Pending
200
+ style = pc.dim;
201
+ }
202
+
203
+ const current = phase === currentPhase ? ' ← current' : '';
204
+ console.log(` ${style(indicator)} ${style(config.label)} ${pc.dim(`- ${config.description}`)}${pc.dim(current)}`);
205
+ }
206
+ console.log('');
207
+ }
208
+
209
+ /**
210
+ * Get the index of a phase for comparison
211
+ */
212
+ function getPhaseIndex(phase: Phase): number {
213
+ const order: Phase[] = ['context', 'goals', 'interview', 'generation', 'complete'];
214
+ return order.indexOf(phase);
215
+ }
216
+
217
+ /**
218
+ * Display a warning for garbled input
219
+ */
220
+ export function displayGarbledInputWarning(received: string): void {
221
+ console.log('');
222
+ console.log(pc.yellow(`⚠️ Received incomplete input: "${received.slice(0, 50)}${received.length > 50 ? '...' : ''}"`));
223
+ console.log(pc.dim(' Please paste again or type your answer.'));
224
+ }
225
+
226
+ /**
227
+ * Display AI prefix before streaming
228
+ */
229
+ export function displayAIPrefix(): void {
230
+ process.stdout.write(simpson.blue('AI: '));
231
+ }
232
+
233
+ /**
234
+ * Display a simple separator line
235
+ */
236
+ export function displaySeparator(): void {
237
+ console.log(pc.dim('─'.repeat(60)));
238
+ }
239
+
240
+ /**
241
+ * Display thinking indicator
242
+ */
243
+ export function displayThinking(): void {
244
+ process.stdout.write(pc.dim('Thinking...'));
245
+ }
246
+
247
+ /**
248
+ * Clear thinking indicator
249
+ */
250
+ export function clearThinking(): void {
251
+ // Move cursor back and clear line
252
+ process.stdout.write('\r' + ' '.repeat(20) + '\r');
253
+ }
254
+
255
+ /**
256
+ * Format bytes for display
257
+ */
258
+ export function formatBytes(bytes: number): string {
259
+ if (bytes < 1024) return `${bytes}B`;
260
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
261
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
262
+ }