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.
- package/dist/index.js +7 -6
- package/dist/tui/app.d.ts +12 -22
- package/dist/tui/app.js +130 -314
- package/dist/tui/components/AppShell.d.ts +47 -0
- package/dist/tui/components/AppShell.js +19 -0
- package/dist/tui/components/FooterStatusBar.js +2 -3
- package/dist/tui/components/HeaderContent.d.ts +28 -0
- package/dist/tui/components/HeaderContent.js +16 -0
- package/dist/tui/components/MessageList.d.ts +9 -7
- package/dist/tui/components/MessageList.js +23 -17
- package/dist/tui/components/RunCompletionSummary.d.ts +22 -0
- package/dist/tui/components/RunCompletionSummary.js +23 -0
- package/dist/tui/components/SpecCompletionSummary.d.ts +47 -0
- package/dist/tui/components/SpecCompletionSummary.js +124 -0
- package/dist/tui/components/TipsBar.d.ts +24 -0
- package/dist/tui/components/TipsBar.js +23 -0
- package/dist/tui/components/WiggumBanner.js +8 -3
- package/dist/tui/hooks/useBackgroundRuns.d.ts +52 -0
- package/dist/tui/hooks/useBackgroundRuns.js +121 -0
- package/dist/tui/orchestration/interview-orchestrator.js +1 -1
- package/dist/tui/screens/InitScreen.d.ts +13 -8
- package/dist/tui/screens/InitScreen.js +86 -87
- package/dist/tui/screens/InterviewScreen.d.ts +11 -8
- package/dist/tui/screens/InterviewScreen.js +145 -99
- package/dist/tui/screens/MainShell.d.ts +13 -12
- package/dist/tui/screens/MainShell.js +65 -69
- package/dist/tui/screens/RunScreen.d.ts +17 -1
- package/dist/tui/screens/RunScreen.js +235 -80
- package/dist/tui/screens/index.d.ts +0 -2
- package/dist/tui/screens/index.js +0 -1
- package/dist/tui/utils/loop-status.d.ts +22 -3
- package/dist/tui/utils/loop-status.js +65 -15
- package/package.json +5 -1
- package/dist/tui/screens/WelcomeScreen.d.ts +0 -44
- 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.
|
|
20
|
+
return pkg.version || '0.12.1';
|
|
21
21
|
}
|
|
22
|
-
catch {
|
|
23
|
-
|
|
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 = '
|
|
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
|
|
98
|
+
// No args = start with shell
|
|
98
99
|
if (args.length === 0) {
|
|
99
|
-
await startInkTui('
|
|
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
|
|
8
|
-
*
|
|
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 = '
|
|
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
|
-
|
|
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
|
|
9
|
-
*
|
|
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 {
|
|
13
|
-
import { existsSync, mkdirSync, writeFileSync
|
|
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 {
|
|
21
|
-
import {
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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
|
|
45
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
53
|
+
* Handle interview completion - save spec to disk and navigate to shell
|
|
84
54
|
*/
|
|
85
|
-
const handleInterviewComplete = useCallback(async (spec, messages) => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
105
|
-
// If saving fails, still call onComplete with spec content
|
|
84
|
+
else {
|
|
106
85
|
onComplete?.(spec);
|
|
107
86
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
|
118
|
+
* Handle init completion - update state and navigate to shell
|
|
282
119
|
*/
|
|
283
|
-
const
|
|
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
|
|
129
|
+
* Handle run completion - dismiss background run if any, navigate to shell
|
|
294
130
|
*/
|
|
295
|
-
const
|
|
296
|
-
//
|
|
297
|
-
|
|
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
|
-
}, [
|
|
135
|
+
}, [dismiss, navigate]);
|
|
301
136
|
/**
|
|
302
|
-
* Handle run
|
|
137
|
+
* Handle run background - add to background tracking, navigate to shell
|
|
303
138
|
*/
|
|
304
|
-
const
|
|
305
|
-
|
|
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
|
-
}, [
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
}
|