wiggum-cli 0.12.1 → 0.13.1

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 (37) hide show
  1. package/dist/index.js +7 -6
  2. package/dist/templates/prompts/PROMPT_review_auto.md.tmpl +19 -23
  3. package/dist/tui/app.d.ts +12 -22
  4. package/dist/tui/app.js +130 -314
  5. package/dist/tui/components/AppShell.d.ts +47 -0
  6. package/dist/tui/components/AppShell.js +19 -0
  7. package/dist/tui/components/FooterStatusBar.js +2 -3
  8. package/dist/tui/components/HeaderContent.d.ts +28 -0
  9. package/dist/tui/components/HeaderContent.js +16 -0
  10. package/dist/tui/components/MessageList.d.ts +9 -7
  11. package/dist/tui/components/MessageList.js +23 -17
  12. package/dist/tui/components/RunCompletionSummary.d.ts +22 -0
  13. package/dist/tui/components/RunCompletionSummary.js +23 -0
  14. package/dist/tui/components/SpecCompletionSummary.d.ts +47 -0
  15. package/dist/tui/components/SpecCompletionSummary.js +124 -0
  16. package/dist/tui/components/TipsBar.d.ts +24 -0
  17. package/dist/tui/components/TipsBar.js +23 -0
  18. package/dist/tui/components/WiggumBanner.js +8 -3
  19. package/dist/tui/hooks/useBackgroundRuns.d.ts +52 -0
  20. package/dist/tui/hooks/useBackgroundRuns.js +121 -0
  21. package/dist/tui/orchestration/interview-orchestrator.js +1 -1
  22. package/dist/tui/screens/InitScreen.d.ts +13 -8
  23. package/dist/tui/screens/InitScreen.js +86 -87
  24. package/dist/tui/screens/InterviewScreen.d.ts +11 -8
  25. package/dist/tui/screens/InterviewScreen.js +145 -99
  26. package/dist/tui/screens/MainShell.d.ts +13 -12
  27. package/dist/tui/screens/MainShell.js +65 -69
  28. package/dist/tui/screens/RunScreen.d.ts +17 -1
  29. package/dist/tui/screens/RunScreen.js +235 -80
  30. package/dist/tui/screens/index.d.ts +0 -2
  31. package/dist/tui/screens/index.js +0 -1
  32. package/dist/tui/utils/loop-status.d.ts +22 -3
  33. package/dist/tui/utils/loop-status.js +65 -15
  34. package/package.json +5 -1
  35. package/src/templates/prompts/PROMPT_review_auto.md.tmpl +19 -23
  36. package/dist/tui/screens/WelcomeScreen.d.ts +0 -44
  37. package/dist/tui/screens/WelcomeScreen.js +0 -54
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * InterviewScreen - Main screen for the /new command interview flow
4
4
  *
@@ -8,55 +8,54 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
8
8
  * 2. Goals - Understand what to build
9
9
  * 3. Interview - Clarifying questions
10
10
  * 4. Generation - Generate the specification
11
+ * 5. Complete - Show summary and return to shell
12
+ *
13
+ * Wrapped in AppShell for consistent layout. On completion,
14
+ * shows SpecCompletionSummary inline before returning to shell.
11
15
  */
12
16
  import { useEffect, useCallback, useRef, useState } from 'react';
13
17
  import { Box, Text, useInput } from 'ink';
14
- import { FooterStatusBar } from '../components/FooterStatusBar.js';
18
+ import { logger } from '../../utils/logger.js';
15
19
  import { MessageList } from '../components/MessageList.js';
16
- import { WorkingIndicator } from '../components/WorkingIndicator.js';
17
20
  import { ChatInput } from '../components/ChatInput.js';
18
21
  import { MultiSelect } from '../components/MultiSelect.js';
22
+ import { AppShell } from '../components/AppShell.js';
23
+ import { SpecCompletionSummary } from '../components/SpecCompletionSummary.js';
19
24
  import { useSpecGenerator, PHASE_CONFIGS, TOTAL_DISPLAY_PHASES, } from '../hooks/useSpecGenerator.js';
20
25
  import { InterviewOrchestrator } from '../orchestration/interview-orchestrator.js';
21
- import { theme } from '../theme.js';
26
+ import { theme, phase } from '../theme.js';
22
27
  import { loadContext, toScanResultFromPersisted, getContextAge, } from '../../context/index.js';
28
+ import { join } from 'node:path';
23
29
  import { initTracing, flushTracing } from '../../utils/tracing.js';
24
30
  import { resolveOptionLabels } from '../types/interview.js';
25
31
  /**
26
32
  * InterviewScreen component
27
33
  *
28
34
  * The main screen for the /new command interview flow. Combines all TUI
29
- * components (MessageList, WorkingIndicator, ChatInput, MultiSelect,
30
- * FooterStatusBar) to create the complete interview experience.
31
- *
32
- * Uses the useSpecGenerator hook for state and InterviewOrchestrator
33
- * to bridge to the AI conversation.
35
+ * components within an AppShell layout.
34
36
  */
35
- export function InterviewScreen({ featureName, projectRoot, provider, model, scanResult, specsPath = '.ralph/specs', onComplete, onCancel, }) {
37
+ export function InterviewScreen({ header, featureName, projectRoot, provider, model, scanResult, specsPath = '.ralph/specs', onComplete, onCancel, }) {
36
38
  const { state, initialize, addMessage, addStreamingMessage, updateStreamingMessage, completeStreamingMessage, startToolCall, completeToolCall, setPhase, setGeneratedSpec, setError, setWorking, setReady, } = useSpecGenerator();
37
- // Track orchestrator instance
38
39
  const orchestratorRef = useRef(null);
39
- // Track if we're in streaming mode for the current message
40
40
  const isStreamingRef = useRef(false);
41
41
  const streamContentRef = useRef('');
42
- // Track if we're in generation phase (more reliable than checking orchestrator)
43
42
  const isGeneratingRef = useRef(false);
44
- // Track if component is unmounted to prevent callbacks after cleanup
45
43
  const isCancelledRef = useRef(false);
46
- // Use refs for callbacks and state to avoid stale closures
47
44
  const onCompleteRef = useRef(onComplete);
48
45
  onCompleteRef.current = onComplete;
49
- // Track messages in ref for access in callbacks
50
46
  const messagesRef = useRef(state.messages);
51
47
  messagesRef.current = state.messages;
52
- // State for tool call expansion (Ctrl+O toggle)
53
48
  const [toolCallsExpanded, setToolCallsExpanded] = useState(false);
54
- // State for multi-select interview questions (null = free-text mode)
55
49
  const [currentQuestion, setCurrentQuestion] = useState(null);
56
- // Initialize Braintrust tracing for this interview session.
57
- // flushTracing is fire-and-forget (void) to avoid blocking TUI shutdown.
50
+ // Completion state: when spec is done, show summary inline
51
+ const [completionData, setCompletionData] = useState(null);
58
52
  useEffect(() => {
59
- initTracing();
53
+ try {
54
+ initTracing();
55
+ }
56
+ catch (err) {
57
+ logger.error(`Failed to init tracing: ${err instanceof Error ? err.message : String(err)}`);
58
+ }
60
59
  return () => {
61
60
  void flushTracing();
62
61
  };
@@ -70,17 +69,13 @@ export function InterviewScreen({ featureName, projectRoot, provider, model, sca
70
69
  provider,
71
70
  model,
72
71
  });
73
- // Async IIFE to allow loading persisted context
74
72
  (async () => {
75
- // Determine session context: use scanResult prop if available,
76
- // otherwise try loading persisted context from disk
77
73
  let resolvedScanResult = scanResult;
78
74
  let resolvedSessionContext;
79
75
  if (!scanResult) {
80
76
  try {
81
77
  const persisted = await loadContext(projectRoot);
82
78
  if (persisted) {
83
- // Map persisted AI analysis to SessionContext
84
79
  resolvedSessionContext = {
85
80
  entryPoints: persisted.aiAnalysis.projectContext?.entryPoints,
86
81
  keyDirectories: persisted.aiAnalysis.projectContext?.keyDirectories,
@@ -89,16 +84,15 @@ export function InterviewScreen({ featureName, projectRoot, provider, model, sca
89
84
  implementationGuidelines: persisted.aiAnalysis.implementationGuidelines,
90
85
  keyPatterns: persisted.aiAnalysis.technologyPractices?.practices,
91
86
  };
92
- // Rehydrate a minimal scan result for Project Tech Stack context
93
87
  resolvedScanResult = toScanResultFromPersisted(persisted.scanResult, projectRoot);
94
88
  const { human } = getContextAge(persisted);
95
89
  addMessage('system', `Using cached project context from .ralph/.context.json (updated ${human} ago). Run /sync to refresh.`);
96
90
  }
97
91
  }
98
92
  catch (err) {
99
- // Show error but continue without context
100
93
  if (!isCancelledRef.current) {
101
- addMessage('system', `Unable to load cached project context; continuing without it.`);
94
+ const reason = err instanceof Error ? err.message : String(err);
95
+ addMessage('system', `Unable to load cached project context (${reason}); continuing without it.`);
102
96
  }
103
97
  }
104
98
  }
@@ -160,7 +154,9 @@ export function InterviewScreen({ featureName, projectRoot, provider, model, sca
160
154
  if (isCancelledRef.current)
161
155
  return;
162
156
  setGeneratedSpec(spec);
163
- onCompleteRef.current(spec, messagesRef.current);
157
+ // Show completion summary inline instead of navigating away immediately
158
+ const specFilePath = join(projectRoot, specsPath, `${featureName}.md`);
159
+ setCompletionData({ spec, specPath: specFilePath });
164
160
  },
165
161
  onError: (error) => {
166
162
  if (isCancelledRef.current)
@@ -184,114 +180,153 @@ export function InterviewScreen({ featureName, projectRoot, provider, model, sca
184
180
  },
185
181
  });
186
182
  orchestratorRef.current = orchestrator;
187
- orchestrator.start();
188
- })();
183
+ try {
184
+ orchestrator.start();
185
+ }
186
+ catch (err) {
187
+ if (!isCancelledRef.current) {
188
+ const reason = err instanceof Error ? err.message : String(err);
189
+ logger.error(`Orchestrator start failed: ${reason}`);
190
+ setError(reason);
191
+ }
192
+ }
193
+ })().catch((err) => {
194
+ if (!isCancelledRef.current) {
195
+ const reason = err instanceof Error ? err.message : String(err);
196
+ logger.error(`Interview initialization failed: ${reason}`);
197
+ setError(reason);
198
+ }
199
+ });
189
200
  return () => {
190
201
  isCancelledRef.current = true;
191
202
  orchestratorRef.current = null;
192
203
  };
193
- }, [featureName, projectRoot, provider, model, scanResult]);
194
- // Handle user input submission based on current phase
204
+ }, [featureName, projectRoot, provider, model, scanResult, specsPath]);
195
205
  const handleSubmit = useCallback(async (value) => {
196
- const orchestrator = orchestratorRef.current;
197
- if (!orchestrator)
198
- return;
199
- // Add user message to display
200
- if (value) {
201
- addMessage('user', value);
206
+ try {
207
+ const orchestrator = orchestratorRef.current;
208
+ if (!orchestrator) {
209
+ logger.debug('Interview submit ignored: orchestrator not ready yet');
210
+ return;
211
+ }
212
+ if (value) {
213
+ addMessage('user', value);
214
+ }
215
+ const currentPhase = orchestrator.getPhase();
216
+ switch (currentPhase) {
217
+ case 'context':
218
+ if (value) {
219
+ await orchestrator.addReference(value);
220
+ }
221
+ else {
222
+ await orchestrator.advanceToGoals();
223
+ }
224
+ break;
225
+ case 'goals':
226
+ await orchestrator.submitGoals(value);
227
+ break;
228
+ case 'interview':
229
+ if (value.toLowerCase() === 'done' || value.toLowerCase() === 'skip') {
230
+ await orchestrator.skipToGeneration();
231
+ }
232
+ else {
233
+ const answer = {
234
+ mode: 'freeText',
235
+ questionId: currentQuestion?.id || '',
236
+ text: value,
237
+ };
238
+ await orchestrator.submitAnswer(answer);
239
+ }
240
+ break;
241
+ default:
242
+ break;
243
+ }
202
244
  }
203
- const currentPhase = orchestrator.getPhase();
204
- switch (currentPhase) {
205
- case 'context':
206
- if (value) {
207
- // User entered a reference URL/path
208
- await orchestrator.addReference(value);
209
- }
210
- else {
211
- // Empty input = done with context, advance to goals
212
- await orchestrator.advanceToGoals();
213
- }
214
- break;
215
- case 'goals':
216
- // User entered their goals
217
- await orchestrator.submitGoals(value);
218
- break;
219
- case 'interview':
220
- if (value.toLowerCase() === 'done' || value.toLowerCase() === 'skip') {
221
- // Skip to generation
222
- await orchestrator.skipToGeneration();
223
- }
224
- else {
225
- // Submit free-text answer
226
- const answer = {
227
- mode: 'freeText',
228
- questionId: currentQuestion?.id || '',
229
- text: value,
230
- };
231
- await orchestrator.submitAnswer(answer);
232
- }
233
- break;
234
- default:
235
- // In generation or complete phase, ignore input
236
- break;
245
+ catch (error) {
246
+ const reason = error instanceof Error ? error.message : String(error);
247
+ logger.error(`Interview submit failed: ${reason}`);
248
+ setError(reason);
237
249
  }
238
- }, [addMessage, currentQuestion]);
239
- // Handle multi-select answer submission
250
+ }, [addMessage, currentQuestion, setError]);
240
251
  const handleMultiSelectSubmit = useCallback(async (selectedValues) => {
241
252
  try {
242
253
  const orchestrator = orchestratorRef.current;
243
254
  if (!orchestrator || !currentQuestion)
244
255
  return;
245
- // Add user message to display
256
+ // Capture question before clearing so MultiSelect disappears immediately
257
+ const question = currentQuestion;
258
+ setCurrentQuestion(null);
246
259
  if (selectedValues.length === 0) {
247
260
  addMessage('user', '(No options selected)');
248
261
  }
249
262
  else {
250
- const labels = resolveOptionLabels(currentQuestion.options, selectedValues);
263
+ const labels = resolveOptionLabels(question.options, selectedValues);
251
264
  addMessage('user', labels.join(', '));
252
265
  }
253
- // Submit multi-select answer
254
266
  const answer = {
255
267
  mode: 'multiSelect',
256
- questionId: currentQuestion.id,
268
+ questionId: question.id,
257
269
  selectedOptionIds: selectedValues,
258
270
  };
259
271
  await orchestrator.submitAnswer(answer);
260
272
  }
261
273
  catch (error) {
262
- setError(error instanceof Error ? error.message : String(error));
274
+ const reason = error instanceof Error ? error.message : String(error);
275
+ logger.error(`Interview multi-select submit failed: ${reason}`);
276
+ setError(reason);
263
277
  }
264
278
  }, [addMessage, currentQuestion, setError]);
265
- // Handle "Chat about this" mode switch
266
279
  const handleChatMode = useCallback(() => {
267
280
  setCurrentQuestion(null);
268
281
  }, []);
269
- // Handle keyboard input for Escape key and Ctrl+O
282
+ // Handle completion dismiss (user presses Enter or Esc on summary)
283
+ const handleCompletionDismiss = useCallback(() => {
284
+ if (completionData) {
285
+ onCompleteRef.current(completionData.spec, messagesRef.current, completionData.specPath);
286
+ }
287
+ }, [completionData]);
270
288
  useInput((input, key) => {
289
+ // If showing completion summary, Enter or Esc dismisses
290
+ if (completionData) {
291
+ if (key.return || key.escape) {
292
+ handleCompletionDismiss();
293
+ }
294
+ return;
295
+ }
271
296
  if (key.escape) {
272
- // When in multiSelect mode, Escape switches back to free-text instead of cancelling
273
297
  if (currentQuestion) {
274
298
  setCurrentQuestion(null);
275
299
  return;
276
300
  }
277
301
  onCancel();
278
302
  }
279
- // Ctrl+O to toggle tool call expansion
280
303
  if (key.ctrl && input === 'o') {
281
304
  setToolCallsExpanded((prev) => !prev);
282
305
  }
283
306
  });
284
- // Get current phase configuration
285
307
  const phaseConfig = PHASE_CONFIGS[state.phase];
286
- // Determine if input should be disabled
287
308
  const inputDisabled = !state.awaitingInput || state.isWorking || state.phase === 'complete';
288
- // Build the working indicator state
289
- const workingState = {
290
- isWorking: state.isWorking,
291
- status: state.workingStatus,
292
- hint: 'esc to cancel',
309
+ // Get tips text based on phase
310
+ const getTips = () => {
311
+ if (completionData)
312
+ return null;
313
+ switch (state.phase) {
314
+ case 'context':
315
+ return 'Enter URLs or file paths. Empty input to continue.';
316
+ case 'goals':
317
+ return 'Describe what you want to build.';
318
+ case 'interview':
319
+ return currentQuestion
320
+ ? 'Space select, Enter confirm, C to chat, Esc cancel'
321
+ : "Type answer, 'done' to generate, Esc cancel";
322
+ case 'generation':
323
+ return 'Generating specification\u2026 Esc to cancel';
324
+ case 'complete':
325
+ return 'Enter to return to shell';
326
+ default:
327
+ return null;
328
+ }
293
329
  };
294
- // Get placeholder text based on phase
295
330
  const getPlaceholder = () => {
296
331
  switch (state.phase) {
297
332
  case 'context':
@@ -308,13 +343,24 @@ export function InterviewScreen({ featureName, projectRoot, provider, model, sca
308
343
  return 'Type your response...';
309
344
  }
310
345
  };
311
- // Build phase string for status line
312
346
  const totalPhases = state.phase === 'complete' ? PHASE_CONFIGS.complete.number : TOTAL_DISPLAY_PHASES;
313
347
  const phaseString = `${phaseConfig.name} (${phaseConfig.number}/${totalPhases})`;
314
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [state.error && (_jsx(Box, { marginY: 1, children: _jsxs(Text, { color: theme.colors.error, children: ["Error: ", state.error] }) })), _jsx(Box, { marginY: 1, children: _jsx(MessageList, { messages: state.messages, toolCallsExpanded: toolCallsExpanded }) }), state.isWorking && (_jsx(Box, { marginY: 1, children: _jsx(WorkingIndicator, { state: workingState, variant: "active" }) })), state.phase === 'complete' && (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: theme.colors.success, children: [theme.chars.bullet, " "] }), _jsx(Text, { children: "Specification complete." })] })), state.phase !== 'complete' && (_jsx(Box, { marginTop: 1, children: state.phase === 'interview' && currentQuestion ? (_jsxs(_Fragment, { children: [_jsx(Box, { children: _jsx(Text, { dimColor: true, children: '─'.repeat(50) }) }), _jsx(Box, { marginTop: 1, children: _jsx(MultiSelect, { message: currentQuestion.text, options: currentQuestion.options.map(opt => ({
315
- value: opt.id,
316
- label: opt.label,
317
- })), onSubmit: handleMultiSelectSubmit, onChatMode: handleChatMode }) })] })) : (
318
- // Free-text mode (default for all phases)
319
- _jsx(ChatInput, { onSubmit: handleSubmit, disabled: inputDisabled, allowEmpty: state.phase === 'context', placeholder: getPlaceholder() })) })), _jsx(FooterStatusBar, { action: "New Spec", phase: phaseString, path: featureName })] }));
348
+ // Build input element based on phase
349
+ let inputElement = null;
350
+ if (!completionData && state.phase !== 'complete') {
351
+ if (state.phase === 'interview' && currentQuestion) {
352
+ inputElement = (_jsx(MultiSelect, { message: currentQuestion.text, options: currentQuestion.options.map(opt => ({
353
+ value: opt.id,
354
+ label: opt.label,
355
+ })), onSubmit: handleMultiSelectSubmit, onChatMode: handleChatMode }, currentQuestion.id));
356
+ }
357
+ else {
358
+ inputElement = (_jsx(ChatInput, { onSubmit: handleSubmit, disabled: inputDisabled, allowEmpty: state.phase === 'context', placeholder: getPlaceholder() }));
359
+ }
360
+ }
361
+ return (_jsx(AppShell, { header: header, tips: getTips(), isWorking: state.isWorking && !completionData, workingStatus: state.workingStatus, workingHint: "esc to cancel", error: state.error, input: inputElement, footerStatus: {
362
+ action: 'New Spec',
363
+ phase: phaseString,
364
+ path: featureName,
365
+ }, children: completionData ? (_jsx(SpecCompletionSummary, { featureName: featureName, spec: completionData.spec, specPath: completionData.specPath, messages: state.messages })) : (_jsxs(_Fragment, { children: [_jsx(MessageList, { messages: state.messages, toolCallsExpanded: toolCallsExpanded }), state.phase === 'complete' && !completionData && (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: theme.colors.success, children: [phase.complete, " "] }), _jsx(Text, { children: "Specification complete." })] }))] })) }));
320
366
  }
@@ -3,43 +3,44 @@
3
3
  *
4
4
  * The main interactive shell for Wiggum CLI, replacing the readline REPL.
5
5
  * Handles slash commands and provides navigation to other screens.
6
+ * Wrapped in AppShell for consistent layout.
6
7
  */
7
8
  import React from 'react';
8
9
  import type { SessionState } from '../../repl/session-state.js';
10
+ import type { BackgroundRun } from '../hooks/useBackgroundRuns.js';
9
11
  /**
10
12
  * Navigation targets for the shell
11
13
  */
12
- export type NavigationTarget = 'welcome' | 'shell' | 'interview' | 'init' | 'run';
14
+ export type NavigationTarget = 'shell' | 'interview' | 'init' | 'run';
13
15
  /**
14
16
  * Navigation props passed to target screens
15
17
  */
16
18
  export interface NavigationProps {
17
19
  featureName?: string;
20
+ monitorOnly?: boolean;
18
21
  [key: string]: unknown;
19
22
  }
20
23
  /**
21
24
  * Props for MainShell component
22
25
  */
23
26
  export interface MainShellProps {
27
+ /** Pre-built header element from App */
28
+ header: React.ReactNode;
24
29
  /** Current session state */
25
30
  sessionState: SessionState;
26
31
  /** Called when navigating to another screen */
27
32
  onNavigate: (target: NavigationTarget, props?: NavigationProps) => void;
28
- /** Called when session state changes */
29
- onSessionStateChange?: (state: SessionState) => void;
33
+ /** Active background runs */
34
+ backgroundRuns?: BackgroundRun[];
35
+ /** Message to display when the shell first mounts (e.g. from init completion) */
36
+ initialMessage?: string;
37
+ /** File paths to display as dimmed lines below the initial message */
38
+ initialFiles?: string[];
30
39
  }
31
40
  /**
32
41
  * MainShell component
33
42
  *
34
43
  * The main interactive shell that handles slash commands and navigation.
35
44
  * Replaces the readline-based REPL with an Ink-powered TUI.
36
- *
37
- * @example
38
- * ```tsx
39
- * <MainShell
40
- * sessionState={state}
41
- * onNavigate={(target, props) => setScreen(target, props)}
42
- * />
43
- * ```
44
45
  */
45
- export declare function MainShell({ sessionState, onNavigate, }: MainShellProps): React.ReactElement;
46
+ export declare function MainShell({ header, sessionState, onNavigate, backgroundRuns, initialMessage, initialFiles, }: MainShellProps): React.ReactElement;