wiggum-cli 0.12.1 → 0.13.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 (35) hide show
  1. package/dist/index.js +7 -6
  2. package/dist/tui/app.d.ts +12 -22
  3. package/dist/tui/app.js +130 -314
  4. package/dist/tui/components/AppShell.d.ts +47 -0
  5. package/dist/tui/components/AppShell.js +19 -0
  6. package/dist/tui/components/FooterStatusBar.js +2 -3
  7. package/dist/tui/components/HeaderContent.d.ts +28 -0
  8. package/dist/tui/components/HeaderContent.js +16 -0
  9. package/dist/tui/components/MessageList.d.ts +9 -7
  10. package/dist/tui/components/MessageList.js +23 -17
  11. package/dist/tui/components/RunCompletionSummary.d.ts +22 -0
  12. package/dist/tui/components/RunCompletionSummary.js +23 -0
  13. package/dist/tui/components/SpecCompletionSummary.d.ts +47 -0
  14. package/dist/tui/components/SpecCompletionSummary.js +124 -0
  15. package/dist/tui/components/TipsBar.d.ts +24 -0
  16. package/dist/tui/components/TipsBar.js +23 -0
  17. package/dist/tui/components/WiggumBanner.js +8 -3
  18. package/dist/tui/hooks/useBackgroundRuns.d.ts +52 -0
  19. package/dist/tui/hooks/useBackgroundRuns.js +121 -0
  20. package/dist/tui/orchestration/interview-orchestrator.js +1 -1
  21. package/dist/tui/screens/InitScreen.d.ts +13 -8
  22. package/dist/tui/screens/InitScreen.js +86 -87
  23. package/dist/tui/screens/InterviewScreen.d.ts +11 -8
  24. package/dist/tui/screens/InterviewScreen.js +145 -99
  25. package/dist/tui/screens/MainShell.d.ts +13 -12
  26. package/dist/tui/screens/MainShell.js +65 -69
  27. package/dist/tui/screens/RunScreen.d.ts +17 -1
  28. package/dist/tui/screens/RunScreen.js +235 -80
  29. package/dist/tui/screens/index.d.ts +0 -2
  30. package/dist/tui/screens/index.js +0 -1
  31. package/dist/tui/utils/loop-status.d.ts +22 -3
  32. package/dist/tui/utils/loop-status.js +65 -15
  33. package/package.json +5 -1
  34. package/dist/tui/screens/WelcomeScreen.d.ts +0 -44
  35. package/dist/tui/screens/WelcomeScreen.js +0 -54
package/dist/index.js CHANGED
@@ -17,17 +17,18 @@ function getVersion() {
17
17
  const __dirname = dirname(__filename);
18
18
  const packagePath = join(__dirname, '..', 'package.json');
19
19
  const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'));
20
- return pkg.version || '0.8.0';
20
+ return pkg.version || '0.12.1';
21
21
  }
22
- catch {
23
- return '0.8.0';
22
+ catch (err) {
23
+ logger.debug(`Failed to read version from package.json: ${err instanceof Error ? err.message : String(err)}`);
24
+ return '0.12.1'; // Fallback version (keep in sync with app.tsx)
24
25
  }
25
26
  }
26
27
  /**
27
28
  * Start Ink TUI mode
28
29
  * Called when wiggum is invoked with no arguments or with screen-routing args
29
30
  */
30
- async function startInkTui(initialScreen = 'welcome', interviewFeature) {
31
+ async function startInkTui(initialScreen = 'shell', interviewFeature) {
31
32
  const projectRoot = process.cwd();
32
33
  const version = getVersion();
33
34
  /**
@@ -94,9 +95,9 @@ export async function main() {
94
95
  const args = process.argv.slice(2);
95
96
  // Check for updates (non-blocking, fails silently)
96
97
  await notifyIfUpdateAvailable();
97
- // No args = start with welcome screen
98
+ // No args = start with shell
98
99
  if (args.length === 0) {
99
- await startInkTui('welcome');
100
+ await startInkTui('shell');
100
101
  return;
101
102
  }
102
103
  // Route commands to TUI screens
package/dist/tui/app.d.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  * The root component for the Ink-based TUI. Routes to different screens
5
5
  * based on the current screen state. Manages session state and navigation.
6
6
  *
7
- * Uses a "continuous thread" model like Claude Code - all output stays
8
- * visible in the terminal as a growing thread, rather than clearing screens.
7
+ * Uses an AppShell-based layout where each screen wraps itself in
8
+ * <AppShell> with a shared header element. No Static/thread model -
9
+ * screen transitions are clean React mount/unmount cycles.
9
10
  */
10
11
  import React from 'react';
11
12
  import { type Instance } from 'ink';
@@ -15,7 +16,7 @@ import type { SessionState } from '../repl/session-state.js';
15
16
  /**
16
17
  * Available screen types for the App component
17
18
  */
18
- export type AppScreen = 'welcome' | 'shell' | 'interview' | 'init' | 'run';
19
+ export type AppScreen = 'shell' | 'interview' | 'init' | 'run';
19
20
  /**
20
21
  * Props for the interview screen
21
22
  */
@@ -48,7 +49,14 @@ export interface AppProps {
48
49
  /** Called when the user exits/cancels */
49
50
  onExit?: () => void;
50
51
  }
51
- export declare function App({ screen: initialScreen, initialSessionState, version, interviewProps, onComplete, onExit, }: AppProps): React.ReactElement | null;
52
+ /**
53
+ * Main App component for the Ink-based TUI
54
+ *
55
+ * Simple routing + shared state. Each screen wraps itself in AppShell
56
+ * and receives a shared headerElement prop.
57
+ */
58
+ export declare function App({ screen: initialScreen, initialSessionState, version, // Fallback if package.json read fails (keep in sync with index.ts)
59
+ interviewProps, onComplete, onExit, }: AppProps): React.ReactElement | null;
52
60
  /**
53
61
  * Render options for renderApp
54
62
  */
@@ -68,23 +76,5 @@ export interface RenderAppOptions {
68
76
  }
69
77
  /**
70
78
  * Render the App component to the terminal
71
- *
72
- * Helper function that wraps Ink's render() to provide a clean API
73
- * for starting the TUI from command handlers.
74
- *
75
- * @param options - Render options
76
- * @returns Ink Instance that can be used to control/cleanup the render
77
- *
78
- * @example
79
- * ```typescript
80
- * const instance = renderApp({
81
- * screen: 'welcome',
82
- * initialSessionState: state,
83
- * version: '0.8.0',
84
- * onExit: () => instance.unmount(),
85
- * });
86
- *
87
- * await instance.waitUntilExit();
88
- * ```
89
79
  */
90
80
  export declare function renderApp(options: RenderAppOptions): Instance;
package/dist/tui/app.js CHANGED
@@ -5,73 +5,43 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  * The root component for the Ink-based TUI. Routes to different screens
6
6
  * based on the current screen state. Manages session state and navigation.
7
7
  *
8
- * Uses a "continuous thread" model like Claude Code - all output stays
9
- * visible in the terminal as a growing thread, rather than clearing screens.
8
+ * Uses an AppShell-based layout where each screen wraps itself in
9
+ * <AppShell> with a shared header element. No Static/thread model -
10
+ * screen transitions are clean React mount/unmount cycles.
10
11
  */
11
- import { useState, useCallback, useEffect } from 'react';
12
- import { render, Static, Box, Text } from 'ink';
13
- import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
12
+ import { useState, useCallback, useEffect, useMemo } from 'react';
13
+ import { Box, Text, render, useStdout } from 'ink';
14
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
14
15
  import { join } from 'node:path';
15
16
  import { loadConfigWithDefaults } from '../utils/config.js';
17
+ import { logger } from '../utils/logger.js';
16
18
  import { InterviewScreen } from './screens/InterviewScreen.js';
17
19
  import { InitScreen } from './screens/InitScreen.js';
18
20
  import { RunScreen } from './screens/RunScreen.js';
19
21
  import { MainShell } from './screens/MainShell.js';
20
- import { WiggumBanner } from './components/WiggumBanner.js';
21
- import { StatusLine } from './components/StatusLine.js';
22
- import { PHASE_CONFIGS } from './hooks/useSpecGenerator.js';
23
- import { colors, theme } from './theme.js';
24
- import { formatNumber } from './utils/loop-status.js';
22
+ import { HeaderContent } from './components/HeaderContent.js';
23
+ import { useBackgroundRuns } from './hooks/useBackgroundRuns.js';
25
24
  /**
26
25
  * Main App component for the Ink-based TUI
27
26
  *
28
- * Routes to different screens based on the current screen state.
29
- * Manages session state and provides navigation between screens.
30
- *
31
- * @example
32
- * ```tsx
33
- * renderApp({
34
- * screen: 'welcome',
35
- * initialSessionState: sessionState,
36
- * version: '0.8.0',
37
- * onExit: () => process.exit(0),
38
- * });
39
- * ```
40
- */
41
- /**
42
- * Generate a unique ID for thread items
27
+ * Simple routing + shared state. Each screen wraps itself in AppShell
28
+ * and receives a shared headerElement prop.
43
29
  */
44
- function generateThreadId() {
45
- return `thread-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
46
- }
47
- export function App({ screen: initialScreen, initialSessionState, version = '0.8.0', interviewProps, onComplete, onExit, }) {
30
+ export function App({ screen: initialScreen, initialSessionState, version = '0.12.1', // Fallback if package.json read fails (keep in sync with index.ts)
31
+ interviewProps, onComplete, onExit, }) {
48
32
  const [currentScreen, setCurrentScreen] = useState(initialScreen);
49
33
  const [screenProps, setScreenProps] = useState(interviewProps ? { featureName: interviewProps.featureName } : null);
50
34
  const [sessionState, setSessionState] = useState(initialSessionState);
51
- const renderBannerContent = useCallback((state) => (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(WiggumBanner, {}), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsxs(Text, { color: colors.pink, children: ["v", version] }), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), state.provider ? (_jsxs(Text, { color: colors.blue, children: [state.provider, "/", state.model] })) : (_jsx(Text, { color: colors.orange, children: "not configured" })), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsx(Text, { color: state.initialized ? colors.green : colors.orange, children: state.initialized ? 'Ready' : 'Not initialized' })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Tip: ", state.initialized ? '/new <feature> to create spec' : '/init to set up', ", /help for commands"] }) })] })), [version]);
52
- // Thread history - preserves all output as a continuous thread
53
- const [threadHistory, setThreadHistory] = useState(() => {
54
- // Start with banner if showing welcome screen
55
- if (initialScreen === 'welcome') {
56
- return [{
57
- id: generateThreadId(),
58
- type: 'banner',
59
- content: renderBannerContent(initialSessionState),
60
- }];
61
- }
62
- return [];
63
- });
64
- const [completionQueue, setCompletionQueue] = useState(null);
65
- const [isTransitioning, setIsTransitioning] = useState(false);
66
- const [threadResetKey, setThreadResetKey] = useState(0);
67
- /**
68
- * Add an item to the thread history
69
- */
70
- const addToThread = useCallback((type, content) => {
71
- const id = generateThreadId();
72
- setThreadHistory(prev => [...prev, { id, type, content }]);
73
- return id;
74
- }, []);
35
+ // Background run tracking
36
+ const { runs: backgroundRuns, background, dismiss } = useBackgroundRuns();
37
+ // Terminal dimensions for compact mode and resize reactivity
38
+ const { stdout } = useStdout();
39
+ const columns = stdout?.columns ?? 80;
40
+ const rows = stdout?.rows ?? 24;
41
+ const compact = rows < 20 || columns < 60;
42
+ // Shared header element - includes columns/rows in deps so the
43
+ // header subtree re-renders on terminal resize (banner auto-compacts)
44
+ const headerElement = useMemo(() => (_jsx(HeaderContent, { version: version, sessionState: sessionState, backgroundRuns: backgroundRuns, compact: compact })), [version, sessionState, backgroundRuns, compact, columns, rows]);
75
45
  /**
76
46
  * Navigate to a different screen
77
47
  */
@@ -80,196 +50,63 @@ export function App({ screen: initialScreen, initialSessionState, version = '0.8
80
50
  setCurrentScreen(target);
81
51
  }, []);
82
52
  /**
83
- * Handle interview completion - save spec to disk and add to thread
53
+ * Handle interview completion - save spec to disk and navigate to shell
84
54
  */
85
- const handleInterviewComplete = useCallback(async (spec, messages) => {
86
- // Get feature name from navigation props or initial interview props
87
- const featureName = screenProps?.featureName || interviewProps?.featureName;
88
- let specPath = '';
89
- if (featureName && typeof featureName === 'string') {
90
- try {
91
- // Load config to get specs directory
92
- const config = await loadConfigWithDefaults(sessionState.projectRoot);
93
- const specsDir = join(sessionState.projectRoot, config.paths.specs);
94
- // Create specs directory if it doesn't exist
95
- if (!existsSync(specsDir)) {
96
- mkdirSync(specsDir, { recursive: true });
55
+ const handleInterviewComplete = useCallback(async (spec, messages, specPath) => {
56
+ try {
57
+ const featureName = screenProps?.featureName || interviewProps?.featureName;
58
+ let savedPath = specPath;
59
+ if (featureName && typeof featureName === 'string') {
60
+ try {
61
+ const config = await loadConfigWithDefaults(sessionState.projectRoot);
62
+ const specsDir = join(sessionState.projectRoot, config.paths.specs);
63
+ if (!existsSync(specsDir)) {
64
+ mkdirSync(specsDir, { recursive: true });
65
+ }
66
+ savedPath = join(specsDir, `${featureName}.md`);
67
+ writeFileSync(savedPath, spec, 'utf-8');
68
+ onComplete?.(savedPath);
69
+ }
70
+ catch (err) {
71
+ const reason = err instanceof Error ? err.message : String(err);
72
+ logger.error(`Failed to save spec: ${reason}`);
73
+ // Pass specPath (not raw spec content) to keep the onComplete contract consistent
74
+ onComplete?.(specPath);
75
+ if (initialScreen !== 'interview') {
76
+ navigate('shell', { message: `Warning: spec generated but could not be saved to disk (${reason}).` });
77
+ }
78
+ else {
79
+ onExit?.();
80
+ }
81
+ return;
97
82
  }
98
- // Write spec to file
99
- specPath = join(specsDir, `${featureName}.md`);
100
- writeFileSync(specPath, spec, 'utf-8');
101
- // Call onComplete with the spec path for logging
102
- onComplete?.(specPath);
103
83
  }
104
- catch (error) {
105
- // If saving fails, still call onComplete with spec content
84
+ else {
106
85
  onComplete?.(spec);
107
86
  }
108
- }
109
- else {
110
- onComplete?.(spec);
111
- }
112
- // Prefer previewing the spec from disk if available (ensures consistent output)
113
- let specForPreview = typeof spec === 'string' ? spec : '';
114
- if (specPath && existsSync(specPath)) {
115
- try {
116
- specForPreview = readFileSync(specPath, 'utf-8');
117
- }
118
- catch {
119
- // Ignore read errors and fall back to in-memory spec
87
+ // If started on interview screen directly (--tui mode), exit
88
+ if (initialScreen === 'interview') {
89
+ onExit?.();
90
+ return;
120
91
  }
92
+ navigate('shell', { message: `Spec saved to ${savedPath}` });
121
93
  }
122
- const specLines = specForPreview ? specForPreview.split('\n') : [];
123
- const totalLines = specLines.length;
124
- const previewLines = specLines.slice(0, 5);
125
- const remainingLines = Math.max(0, totalLines - 5);
126
- const MAX_RECAP_SOURCE_LENGTH = 1200;
127
- const userMessages = messages
128
- .filter((msg) => msg.role === 'user')
129
- .map((msg) => msg.content.trim())
130
- .filter((content) => content.length > 0 && content.length <= MAX_RECAP_SOURCE_LENGTH);
131
- const nonUrlUserMessages = userMessages.filter((content) => !/^https?:\/\//i.test(content) && !/^www\./i.test(content));
132
- const assistantParagraphs = messages
133
- .filter((msg) => msg.role === 'assistant' && msg.content && msg.content.length <= MAX_RECAP_SOURCE_LENGTH)
134
- .flatMap((msg) => msg.content.split('\n\n'))
135
- .map((para) => para.replace(/\s+/g, ' ').trim())
136
- .filter((para) => para.length > 0 && para.length <= 320);
137
- const recapCandidates = assistantParagraphs
138
- .map((para) => para.replace(/^[^a-z0-9]+/i, '').trim())
139
- .filter((para) => /^(you want|understood|got it)/i.test(para))
140
- .map((para) => para.split(/next question:/i)[0].trim())
141
- .filter((para) => para.length > 0);
142
- const normalizeRecap = (text) => {
143
- let result = text.trim();
144
- result = result.replace(/^[^a-z0-9]+/i, '');
145
- result = result.replace(/^you want\s*/i, '');
146
- result = result.replace(/^understood[:,]?\s*/i, '');
147
- result = result.replace(/^got it[-—:]*\s*/i, '');
148
- return result.charAt(0).toUpperCase() + result.slice(1);
149
- };
150
- const normalizeUserDecision = (text) => {
151
- let result = text.trim();
152
- result = result.replace(/^[^a-z0-9]+/i, '');
153
- result = result.replace(/^i (?:would like|want|need|prefer|expect) to\s*/i, '');
154
- result = result.replace(/^i (?:would like|want|need|prefer|expect)\s*/i, '');
155
- result = result.replace(/^please\s*/i, '');
156
- result = result.replace(/^up to you[:,]?\s*/i, '');
157
- result = result.replace(/^both\s*/i, 'Both ');
158
- if (result && !/[.!?]$/.test(result)) {
159
- result += '.';
94
+ catch (err) {
95
+ const reason = err instanceof Error ? err.message : String(err);
96
+ logger.error(`Unexpected error in handleInterviewComplete: ${reason}`);
97
+ if (initialScreen !== 'interview') {
98
+ navigate('shell', { message: `Error completing interview: ${reason}` });
160
99
  }
161
- return result.charAt(0).toUpperCase() + result.slice(1);
162
- };
163
- const goalCandidate = recapCandidates.length > 0
164
- ? normalizeRecap(recapCandidates[0])
165
- : (nonUrlUserMessages.find((content) => content.length > 20)
166
- ? normalizeUserDecision(nonUrlUserMessages.find((content) => content.length > 20))
167
- : (nonUrlUserMessages[0] ? normalizeUserDecision(nonUrlUserMessages[0]) : `Define "${featureName}"`));
168
- const summarizeText = (text, max = 160) => {
169
- if (text.length <= max)
170
- return text;
171
- return `${text.slice(0, max - 1)}…`;
172
- };
173
- const decisions = [];
174
- const seen = new Set();
175
- const isUsefulDecision = (entry) => {
176
- const normalized = entry.trim().toLowerCase();
177
- if (normalized.length < 8)
178
- return false;
179
- const wordCount = normalized.split(/\s+/).length;
180
- if (wordCount < 3)
181
- return false;
182
- if (['yes', 'no', 'both', 'ok', 'okay'].includes(normalized))
183
- return false;
184
- return true;
185
- };
186
- for (let i = nonUrlUserMessages.length - 1; i >= 0; i -= 1) {
187
- const entry = nonUrlUserMessages[i];
188
- const normalized = entry.toLowerCase();
189
- if (entry === goalCandidate)
190
- continue;
191
- if (!isUsefulDecision(entry))
192
- continue;
193
- if (entry.length > 160)
194
- continue;
195
- if (seen.has(normalized))
196
- continue;
197
- decisions.unshift(normalizeUserDecision(entry));
198
- seen.add(normalized);
199
- if (decisions.length >= 4)
200
- break;
201
- }
202
- if (recapCandidates.length > 1) {
203
- decisions.length = 0;
204
- seen.clear();
205
- for (let i = 1; i < recapCandidates.length; i += 1) {
206
- const entry = normalizeRecap(recapCandidates[i]);
207
- const normalized = entry.toLowerCase();
208
- if (!isUsefulDecision(entry))
209
- continue;
210
- if (seen.has(normalized))
211
- continue;
212
- decisions.push(entry);
213
- seen.add(normalized);
214
- if (decisions.length >= 4)
215
- break;
100
+ else {
101
+ process.stderr.write(`\nError: ${reason}\n`);
102
+ onExit?.();
216
103
  }
217
104
  }
218
- const summaryContent = (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(StatusLine, { action: "New Spec", phase: `Complete (${PHASE_CONFIGS.complete.number}/${PHASE_CONFIGS.complete.number})`, path: featureName }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Summary" }), _jsxs(Text, { children: ["- Goal: ", summarizeText(goalCandidate)] }), _jsxs(Text, { children: ["- Outcome: Spec written to ", specPath || `${featureName}.md`, " (", totalLines, " lines)"] })] }), decisions.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Key decisions" }), decisions.map((decision, idx) => (_jsxs(Text, { children: [idx + 1, ". ", summarizeText(decision, 120)] }, `${decision}-${idx}`)))] })), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsxs(Text, { color: colors.green, children: [theme.chars.bullet, " "] }), _jsx(Text, { bold: true, children: "Write" }), _jsxs(Text, { dimColor: true, children: ["(", specPath || `${featureName}.md`, ")"] })] }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2514 Wrote ", totalLines, " lines"] }) }), _jsxs(Box, { marginLeft: 4, flexDirection: "column", children: [previewLines.map((line, i) => (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { dimColor: true, children: [String(i + 1).padStart(4), " "] }), _jsx(Text, { dimColor: true, children: line })] }, i))), remainingLines > 0 && (_jsxs(Text, { dimColor: true, children: ["\u2026 +", remainingLines, " lines"] }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsxs(Text, { color: colors.green, children: [theme.chars.bullet, " "] }), _jsx(Text, { children: "Done. Specification generated successfully." })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "What's next:" }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: colors.green, children: "\u203A" }), _jsx(Text, { dimColor: true, children: "Review the spec in your editor" })] }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: colors.green, children: "\u203A" }), _jsx(Text, { color: colors.blue, children: "/help" }), _jsx(Text, { dimColor: true, children: "See all commands" })] })] })] }));
219
- // Hide the live interview UI before appending to the static thread
220
- setIsTransitioning(true);
221
- setCompletionQueue({
222
- summaryContent,
223
- summaryType: 'spec-complete',
224
- action: initialScreen === 'interview' ? 'exit' : 'shell',
225
- });
226
- }, [onComplete, navigate, initialScreen, onExit, screenProps, interviewProps, sessionState.projectRoot, addToThread]);
227
- // Append completion items after the interview UI is hidden
228
- useEffect(() => {
229
- if (!isTransitioning || !completionQueue)
230
- return;
231
- const completionItem = {
232
- id: generateThreadId(),
233
- type: completionQueue.summaryType,
234
- content: completionQueue.summaryContent,
235
- };
236
- const clearScrollback = completionQueue.summaryType === 'spec-complete' ? '\x1b[3J' : '';
237
- process.stdout.write(`${clearScrollback}\x1b[2J\x1b[0;0H`);
238
- if (completionQueue.summaryType === 'spec-complete') {
239
- // Replace the interview thread with banner + completion summary only.
240
- const bannerItem = {
241
- id: generateThreadId(),
242
- type: 'banner',
243
- content: renderBannerContent(sessionState),
244
- };
245
- setThreadHistory([bannerItem, completionItem]);
246
- }
247
- else {
248
- setThreadHistory((prev) => [...prev, completionItem]);
249
- }
250
- setThreadResetKey((prev) => prev + 1);
251
- const action = completionQueue.action;
252
- setCompletionQueue(null);
253
- setTimeout(() => {
254
- if (action === 'exit') {
255
- if (onExit) {
256
- onExit();
257
- return;
258
- }
259
- navigate('shell');
260
- setIsTransitioning(false);
261
- return;
262
- }
263
- navigate('shell');
264
- setIsTransitioning(false);
265
- }, 0);
266
- }, [isTransitioning, completionQueue, navigate, onExit]);
105
+ }, [screenProps, interviewProps, sessionState.projectRoot, onComplete, initialScreen, onExit, navigate]);
267
106
  /**
268
107
  * Handle interview cancel
269
108
  */
270
109
  const handleInterviewCancel = useCallback(() => {
271
- // If started on interview (--tui mode), call onExit to resolve promise
272
- // Otherwise, return to shell
273
110
  if (initialScreen === 'interview') {
274
111
  onExit?.();
275
112
  }
@@ -278,108 +115,87 @@ export function App({ screen: initialScreen, initialSessionState, version = '0.8
278
115
  }
279
116
  }, [navigate, initialScreen, onExit]);
280
117
  /**
281
- * Handle welcome continue
118
+ * Handle init completion - update state and navigate to shell
282
119
  */
283
- const handleWelcomeContinue = useCallback(() => {
284
- navigate('shell');
285
- }, [navigate]);
286
- /**
287
- * Handle session state changes
288
- */
289
- const handleSessionStateChange = useCallback((newState) => {
120
+ const handleInitComplete = useCallback((newState, generatedFiles) => {
290
121
  setSessionState(newState);
291
- }, []);
122
+ const fileCount = generatedFiles?.length ?? 0;
123
+ const msg = fileCount > 0
124
+ ? `\u2713 Initialization complete. Generated ${fileCount} configuration file${fileCount === 1 ? '' : 's'}.`
125
+ : '\u2713 Initialization complete.';
126
+ navigate('shell', { message: msg, generatedFiles });
127
+ }, [navigate]);
292
128
  /**
293
- * Handle init completion - add summary to thread and update state
129
+ * Handle run completion - dismiss background run if any, navigate to shell
294
130
  */
295
- const handleInitComplete = useCallback((newState, generatedFiles) => {
296
- // Add init completion to thread
297
- addToThread('init-complete', (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [generatedFiles && generatedFiles.slice(0, 5).map((file) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: colors.green, children: [theme.chars.bullet, " "] }), _jsx(Text, { bold: true, children: "Write" }), _jsxs(Text, { dimColor: true, children: ["(", file, ")"] })] }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u2514 Created ", file] }) })] }, file))), generatedFiles && generatedFiles.length > 5 && (_jsxs(Text, { dimColor: true, children: [" ... and ", generatedFiles.length - 5, " more files"] })), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsxs(Text, { color: colors.green, children: [theme.chars.bullet, " "] }), _jsx(Text, { children: "Done. Created Ralph configuration files." })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "What's next:" }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: colors.green, children: "\u203A" }), _jsxs(Text, { color: colors.blue, children: ["/new ", '<feature>'] }), _jsx(Text, { dimColor: true, children: "Create a feature specification" })] }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: colors.green, children: "\u203A" }), _jsx(Text, { color: colors.blue, children: "/help" }), _jsx(Text, { dimColor: true, children: "See all commands" })] })] })] })));
298
- setSessionState(newState);
131
+ const handleRunComplete = useCallback((summary) => {
132
+ // Dismiss from background tracking if it was backgrounded
133
+ dismiss(summary.feature);
299
134
  navigate('shell');
300
- }, [addToThread, navigate]);
135
+ }, [dismiss, navigate]);
301
136
  /**
302
- * Handle run completion - add summary to thread
137
+ * Handle run background - add to background tracking, navigate to shell
303
138
  */
304
- const handleRunComplete = useCallback((summary) => {
305
- const totalTokens = summary.tokensInput + summary.tokensOutput;
306
- const stoppedCodes = new Set([130, 143]);
307
- const exitState = summary.exitCode === 0
308
- ? { label: 'Complete', color: colors.green, message: 'Done. Feature loop completed successfully.' }
309
- : stoppedCodes.has(summary.exitCode)
310
- ? { label: 'Stopped', color: colors.orange, message: 'Stopped. Feature loop interrupted.' }
311
- : { label: 'Failed', color: colors.pink, message: `Done. Feature loop exited with code ${summary.exitCode}.` };
312
- addToThread('run-complete', (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(StatusLine, { action: "Run Loop", phase: exitState.label, path: summary.feature }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Summary" }), _jsxs(Text, { children: ["- Feature: ", summary.feature] }), _jsxs(Text, { children: ["- Iterations: ", summary.iterations, "/", summary.maxIterations] }), _jsxs(Text, { children: ["- Tasks: ", summary.tasksDone, "/", summary.tasksTotal] }), _jsxs(Text, { children: ["- Tokens: ", formatNumber(totalTokens), " (in:", formatNumber(summary.tokensInput), " out:", formatNumber(summary.tokensOutput), ")"] }), summary.branch && summary.branch !== '-' && (_jsxs(Text, { children: ["- Branch: ", summary.branch] }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsxs(Text, { color: exitState.color, children: [theme.chars.bullet, " "] }), _jsx(Text, { children: exitState.message })] }), (summary.errorTail || summary.logPath) && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [summary.logPath && (_jsxs(Text, { dimColor: true, children: ["Log: ", summary.logPath] })), summary.errorTail && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Last output:" }), summary.errorTail.split('\n').map((line, idx) => (_jsx(Text, { dimColor: true, children: line }, `${line}-${idx}`)))] }))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "What's next:" }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: colors.green, children: "\u203A" }), _jsx(Text, { dimColor: true, children: "Review changes and open a PR if needed" })] }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: colors.green, children: "\u203A" }), _jsxs(Text, { color: colors.blue, children: ["/new ", '<feature>'] }), _jsx(Text, { dimColor: true, children: "Create another feature specification" })] }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: colors.green, children: "\u203A" }), _jsx(Text, { color: colors.blue, children: "/help" }), _jsx(Text, { dimColor: true, children: "See all commands" })] })] })] })));
139
+ const handleRunBackground = useCallback((featureName) => {
140
+ background(featureName);
313
141
  navigate('shell');
314
- }, [addToThread, navigate]);
315
- // Render current screen content
316
- const renderCurrentScreen = () => {
317
- // Hide screens while we transition interview output to static thread
318
- if (isTransitioning) {
319
- return null;
142
+ }, [background, navigate]);
143
+ // Guard: redirect to shell if screen has invalid props (avoids setState during render)
144
+ useEffect(() => {
145
+ if (currentScreen === 'interview') {
146
+ const featureName = screenProps?.featureName || interviewProps?.featureName;
147
+ if (!featureName || typeof featureName !== 'string') {
148
+ navigate('shell', { message: 'Feature name is required for the interview screen.' });
149
+ return;
150
+ }
151
+ if (!sessionState.provider) {
152
+ navigate('shell', { message: 'No AI provider configured. Run /init first.' });
153
+ }
320
154
  }
321
- switch (currentScreen) {
322
- case 'welcome':
323
- // Banner is already in thread history, fall through to shell
324
- case 'shell':
325
- return (_jsx(MainShell, { sessionState: sessionState, onNavigate: navigate, onSessionStateChange: handleSessionStateChange }));
326
- case 'interview': {
327
- // Get feature name from props or navigation
328
- const featureName = screenProps?.featureName || interviewProps?.featureName;
329
- if (!featureName || typeof featureName !== 'string') {
330
- // Missing feature name, go back to shell
331
- navigate('shell');
332
- return null;
333
- }
334
- if (!sessionState.provider) {
335
- // No provider configured, can't run interview
336
- navigate('shell');
337
- return null;
338
- }
339
- return (_jsx(InterviewScreen, { featureName: featureName, projectRoot: sessionState.projectRoot, provider: sessionState.provider, model: sessionState.model, scanResult: sessionState.scanResult, specsPath: sessionState.config?.paths.specs, onComplete: handleInterviewComplete, onCancel: handleInterviewCancel }));
155
+ else if (currentScreen === 'run') {
156
+ const featureName = screenProps?.featureName;
157
+ if (!featureName || typeof featureName !== 'string') {
158
+ navigate('shell', { message: 'Feature name is required for the run screen.' });
340
159
  }
341
- case 'init': {
342
- return (_jsx(InitScreen, { projectRoot: sessionState.projectRoot, sessionState: sessionState, onComplete: handleInitComplete, onCancel: () => navigate('shell') }));
160
+ }
161
+ else if (currentScreen !== 'shell' && currentScreen !== 'init') {
162
+ // Unknown screen — redirect to shell on next tick
163
+ navigate('shell', { message: `Internal error: unknown screen "${currentScreen}". Returned to shell.` });
164
+ }
165
+ }, [currentScreen, screenProps, interviewProps, sessionState.provider, navigate]);
166
+ // Render current screen
167
+ switch (currentScreen) {
168
+ case 'shell':
169
+ return (_jsx(MainShell, { header: headerElement, sessionState: sessionState, onNavigate: navigate, backgroundRuns: backgroundRuns, initialMessage: typeof screenProps?.message === 'string' ? screenProps.message : undefined, initialFiles: Array.isArray(screenProps?.generatedFiles) ? screenProps.generatedFiles : undefined }, screenProps?.message ? String(screenProps.message) : 'shell'));
170
+ case 'interview': {
171
+ const featureName = screenProps?.featureName || interviewProps?.featureName;
172
+ if (!featureName || typeof featureName !== 'string' || !sessionState.provider) {
173
+ return null; // useEffect will redirect to shell
343
174
  }
344
- case 'run': {
345
- const featureName = screenProps?.featureName;
346
- if (!featureName || typeof featureName !== 'string') {
347
- navigate('shell');
348
- return null;
349
- }
350
- return (_jsx(RunScreen, { featureName: featureName, projectRoot: sessionState.projectRoot, sessionState: sessionState, onComplete: handleRunComplete, onCancel: () => navigate('shell') }));
175
+ return (_jsx(InterviewScreen, { header: headerElement, featureName: featureName, projectRoot: sessionState.projectRoot, provider: sessionState.provider, model: sessionState.model, scanResult: sessionState.scanResult, specsPath: sessionState.config?.paths.specs, onComplete: handleInterviewComplete, onCancel: handleInterviewCancel }));
176
+ }
177
+ case 'init':
178
+ return (_jsx(InitScreen, { header: headerElement, projectRoot: sessionState.projectRoot, sessionState: sessionState, onComplete: handleInitComplete, onCancel: () => navigate('shell') }));
179
+ case 'run': {
180
+ const featureName = screenProps?.featureName;
181
+ const monitorOnly = screenProps?.monitorOnly === true;
182
+ if (!featureName || typeof featureName !== 'string') {
183
+ return null; // useEffect will redirect to shell
351
184
  }
352
- default:
353
- return null;
185
+ return (_jsx(RunScreen, { header: headerElement, featureName: featureName, projectRoot: sessionState.projectRoot, sessionState: sessionState, monitorOnly: monitorOnly, onComplete: handleRunComplete, onBackground: handleRunBackground, onCancel: () => navigate('shell') }));
186
+ }
187
+ default: {
188
+ // Return fallback UI instead of calling navigate() during render (which would be setState during render).
189
+ // The useEffect guard above will redirect to shell on next tick.
190
+ const unknownScreen = currentScreen;
191
+ logger.error(`Unknown screen: ${unknownScreen}`);
192
+ return (_jsx(Box, { flexDirection: "column", padding: 1, children: _jsxs(Text, { color: "red", children: ["Internal error: unknown screen \"", unknownScreen, "\". Redirecting to shell..."] }) }));
354
193
  }
355
- };
356
- // Render with thread history (Static) + current screen
357
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: threadHistory, children: (item) => (_jsx(Box, { children: item.content }, item.id)) }, threadResetKey), !isTransitioning && renderCurrentScreen()] }));
194
+ }
358
195
  }
359
196
  /**
360
197
  * Render the App component to the terminal
361
- *
362
- * Helper function that wraps Ink's render() to provide a clean API
363
- * for starting the TUI from command handlers.
364
- *
365
- * @param options - Render options
366
- * @returns Ink Instance that can be used to control/cleanup the render
367
- *
368
- * @example
369
- * ```typescript
370
- * const instance = renderApp({
371
- * screen: 'welcome',
372
- * initialSessionState: state,
373
- * version: '0.8.0',
374
- * onExit: () => instance.unmount(),
375
- * });
376
- *
377
- * await instance.waitUntilExit();
378
- * ```
379
198
  */
380
199
  export function renderApp(options) {
381
- if (options.screen === 'welcome') {
382
- process.stdout.write('\x1b[3J\x1b[2J\x1b[0;0H');
383
- }
384
200
  return render(_jsx(App, { screen: options.screen, initialSessionState: options.initialSessionState, version: options.version, interviewProps: options.interviewProps, onComplete: options.onComplete, onExit: options.onExit }));
385
201
  }