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.
- package/dist/index.js +7 -6
- package/dist/templates/prompts/PROMPT_review_auto.md.tmpl +19 -23
- 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/src/templates/prompts/PROMPT_review_auto.md.tmpl +19 -23
- package/dist/tui/screens/WelcomeScreen.d.ts +0 -44
- package/dist/tui/screens/WelcomeScreen.js +0 -54
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { WiggumBanner } from './WiggumBanner.js';
|
|
4
|
+
import { colors, theme } from '../theme.js';
|
|
5
|
+
/**
|
|
6
|
+
* HeaderContent component
|
|
7
|
+
*
|
|
8
|
+
* Renders the banner and status row for the AppShell header zone.
|
|
9
|
+
*/
|
|
10
|
+
export function HeaderContent({ version, sessionState, backgroundRuns, compact = false, }) {
|
|
11
|
+
const activeRuns = backgroundRuns?.filter((r) => !r.completed && !r.pollError) ?? [];
|
|
12
|
+
const errorRuns = backgroundRuns?.filter((r) => r.pollError) ?? [];
|
|
13
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(WiggumBanner, { compact: compact }), _jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: colors.pink, children: ["v", version] }), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), sessionState.provider ? (_jsxs(Text, { color: colors.blue, children: [sessionState.provider, "/", sessionState.model] })) : (_jsx(Text, { color: colors.orange, children: "not configured" })), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsx(Text, { color: sessionState.initialized ? colors.green : colors.orange, children: sessionState.initialized ? 'Ready' : 'Not initialized' }), activeRuns.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsxs(Text, { color: colors.green, children: [theme.chars.bulletLarge, " ", activeRuns[0].featureName, activeRuns[0].lastStatus.iteration > 0
|
|
14
|
+
? ` (${activeRuns[0].lastStatus.iteration}/${activeRuns[0].lastStatus.maxIterations || '?'})`
|
|
15
|
+
: ''] }), activeRuns.length > 1 && (_jsxs(Text, { dimColor: true, children: [" +", activeRuns.length - 1, " more"] }))] })), errorRuns.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsxs(Text, { color: colors.orange, children: [theme.chars.bullet, " ", errorRuns[0].featureName, " (status unknown)"] })] }))] })] }));
|
|
16
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Displays the full conversation history with clean formatting:
|
|
5
5
|
* - User messages: › prefix
|
|
6
|
-
* - Assistant messages:
|
|
6
|
+
* - Assistant messages: • dimmed bullet for context, bold header for questions
|
|
7
7
|
* - Tool calls: Inline action indicators
|
|
8
8
|
*/
|
|
9
9
|
import React from 'react';
|
|
@@ -44,7 +44,7 @@ export interface Message {
|
|
|
44
44
|
export interface MessageListProps {
|
|
45
45
|
/** Array of messages to display */
|
|
46
46
|
messages: Message[];
|
|
47
|
-
/** Optional max height in lines (
|
|
47
|
+
/** Optional max height in lines (clips content when set) */
|
|
48
48
|
maxHeight?: number;
|
|
49
49
|
/** Whether tool calls should show expanded preview (default: false) */
|
|
50
50
|
toolCallsExpanded?: boolean;
|
|
@@ -53,21 +53,23 @@ export interface MessageListProps {
|
|
|
53
53
|
* MessageList component
|
|
54
54
|
*
|
|
55
55
|
* Displays the full conversation history with clean styling:
|
|
56
|
-
* - User messages: `› ` prefix in
|
|
57
|
-
* - Assistant messages:
|
|
58
|
-
* - System messages: dimmed text
|
|
56
|
+
* - User messages: `› ` prefix in green
|
|
57
|
+
* - Assistant messages: context with dimmed bullet, questions with bold header
|
|
58
|
+
* - System messages: dimmed text (phase headers in yellow bold)
|
|
59
59
|
*
|
|
60
60
|
* @example
|
|
61
61
|
* ```tsx
|
|
62
62
|
* <MessageList
|
|
63
63
|
* messages={[
|
|
64
64
|
* { id: '1', role: 'user', content: 'Hello' },
|
|
65
|
-
* { id: '2', role: 'assistant', content: '
|
|
65
|
+
* { id: '2', role: 'assistant', content: 'Let me think about that.\n\nWhat framework do you prefer?' },
|
|
66
66
|
* ]}
|
|
67
67
|
* />
|
|
68
68
|
* // Renders:
|
|
69
69
|
* // › Hello
|
|
70
|
-
* //
|
|
70
|
+
* // • Let me think about that.
|
|
71
|
+
* // Next question:
|
|
72
|
+
* // What framework do you prefer?
|
|
71
73
|
* ```
|
|
72
74
|
*/
|
|
73
75
|
export declare function MessageList({ messages, maxHeight, toolCallsExpanded, }: MessageListProps): React.ReactElement;
|
|
@@ -7,24 +7,24 @@ import { ToolCallCard } from './ToolCallCard.js';
|
|
|
7
7
|
* Renders a single user message with › prefix in green
|
|
8
8
|
*/
|
|
9
9
|
function UserMessage({ content }) {
|
|
10
|
-
return (_jsxs(Box, { flexDirection: "row",
|
|
10
|
+
return (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: theme.colors.prompt, bold: true, children: [theme.chars.prompt, ' '] }), _jsx(Text, { color: theme.colors.userText, children: content })] }));
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
* Renders a single assistant message with tool calls
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* Renders a single assistant message with tool calls
|
|
14
|
+
* Context/thinking paragraphs: dimmed bullet prefix, italic
|
|
15
|
+
* Question paragraphs: bold "Next question:" header, no bullet
|
|
16
16
|
*/
|
|
17
17
|
function AssistantMessage({ content, toolCalls, isStreaming, toolCallsExpanded = false, }) {
|
|
18
18
|
// Split content into paragraphs for differentiated styling
|
|
19
19
|
const paragraphs = content ? content.split('\n\n').filter((p) => p.trim()) : [];
|
|
20
|
-
return (_jsxs(Box, { flexDirection: "column",
|
|
20
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [toolCalls && toolCalls.length > 0 && (_jsx(Box, { flexDirection: "column", children: toolCalls.map((toolCall, index) => (_jsx(ToolCallCard, { toolName: toolCall.toolName, status: toolCall.status, input: toolCall.input, output: toolCall.output, error: toolCall.error, expanded: toolCallsExpanded }, `tool-${index}`))) })), content && !isStreaming && (_jsx(Box, { flexDirection: "column", flexGrow: 1, children: paragraphs.map((para, index) => {
|
|
21
21
|
const isQuestion = para.trim().endsWith('?');
|
|
22
22
|
if (isQuestion) {
|
|
23
23
|
// Question - prominent, with "Next question:" prefix
|
|
24
|
-
return (_jsxs(Box, {
|
|
24
|
+
return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Next question:" }), _jsx(Text, { color: theme.colors.aiText, children: para })] }, index));
|
|
25
25
|
}
|
|
26
26
|
else {
|
|
27
|
-
// Context/thinking -
|
|
27
|
+
// Context/thinking - dimmed bullet prefix, italic dimmed text
|
|
28
28
|
return (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { dimColor: true, children: [theme.chars.bullet, " "] }), _jsx(Text, { dimColor: true, italic: true, children: para })] }, index));
|
|
29
29
|
}
|
|
30
30
|
}) })), content && isStreaming && (_jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(StreamingText, { text: content, isStreaming: true, color: theme.colors.aiText }) }))] }));
|
|
@@ -36,43 +36,49 @@ function AssistantMessage({ content, toolCalls, isStreaming, toolCallsExpanded =
|
|
|
36
36
|
function SystemMessage({ content }) {
|
|
37
37
|
// Check if this is a phase header (e.g., "Phase 2: Goals - Describe what you want to build")
|
|
38
38
|
const phaseMatch = content.match(/^Phase (\d+): (.+?) - (.+)$/);
|
|
39
|
+
const isSuccess = content.startsWith('\u2713');
|
|
39
40
|
const isSyncFailure = content.toLowerCase().startsWith('sync failed:');
|
|
40
41
|
const isSyncMessage = content.toLowerCase().startsWith('sync:');
|
|
41
42
|
if (phaseMatch) {
|
|
42
43
|
const [, phaseNum, phaseName, description] = phaseMatch;
|
|
43
|
-
return (_jsxs(Box, {
|
|
44
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.colors.brand, bold: true, children: ["Phase ", phaseNum, ": ", phaseName] }), _jsx(Text, { dimColor: true, children: description })] }));
|
|
45
|
+
}
|
|
46
|
+
if (isSuccess) {
|
|
47
|
+
return (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: colors.green, children: ['\u2713', " "] }), _jsx(Text, { children: content.slice(1).trimStart() })] }));
|
|
44
48
|
}
|
|
45
49
|
if (isSyncFailure) {
|
|
46
|
-
return (_jsx(Box, {
|
|
50
|
+
return (_jsx(Box, { children: _jsx(Text, { color: colors.pink, children: content }) }));
|
|
47
51
|
}
|
|
48
52
|
if (isSyncMessage) {
|
|
49
|
-
return (_jsx(Box, {
|
|
53
|
+
return (_jsx(Box, { children: _jsx(Text, { color: colors.blue, children: content }) }));
|
|
50
54
|
}
|
|
51
|
-
return (_jsx(Box, {
|
|
55
|
+
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: content }) }));
|
|
52
56
|
}
|
|
53
57
|
/**
|
|
54
58
|
* MessageList component
|
|
55
59
|
*
|
|
56
60
|
* Displays the full conversation history with clean styling:
|
|
57
|
-
* - User messages: `› ` prefix in
|
|
58
|
-
* - Assistant messages:
|
|
59
|
-
* - System messages: dimmed text
|
|
61
|
+
* - User messages: `› ` prefix in green
|
|
62
|
+
* - Assistant messages: context with dimmed bullet, questions with bold header
|
|
63
|
+
* - System messages: dimmed text (phase headers in yellow bold)
|
|
60
64
|
*
|
|
61
65
|
* @example
|
|
62
66
|
* ```tsx
|
|
63
67
|
* <MessageList
|
|
64
68
|
* messages={[
|
|
65
69
|
* { id: '1', role: 'user', content: 'Hello' },
|
|
66
|
-
* { id: '2', role: 'assistant', content: '
|
|
70
|
+
* { id: '2', role: 'assistant', content: 'Let me think about that.\n\nWhat framework do you prefer?' },
|
|
67
71
|
* ]}
|
|
68
72
|
* />
|
|
69
73
|
* // Renders:
|
|
70
74
|
* // › Hello
|
|
71
|
-
* //
|
|
75
|
+
* // • Let me think about that.
|
|
76
|
+
* // Next question:
|
|
77
|
+
* // What framework do you prefer?
|
|
72
78
|
* ```
|
|
73
79
|
*/
|
|
74
80
|
export function MessageList({ messages, maxHeight, toolCallsExpanded = false, }) {
|
|
75
|
-
return (_jsx(Box, { flexDirection: "column", ...(maxHeight ? { height: maxHeight } : {}), children: messages.map((message) => {
|
|
81
|
+
return (_jsx(Box, { flexDirection: "column", gap: 1, ...(maxHeight ? { height: maxHeight } : {}), children: messages.map((message) => {
|
|
76
82
|
switch (message.role) {
|
|
77
83
|
case 'user':
|
|
78
84
|
return _jsx(UserMessage, { content: message.content }, message.id);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RunCompletionSummary - Displays run loop completion recap
|
|
3
|
+
*
|
|
4
|
+
* Shows the feature, iterations, tasks, tokens, branch, exit status,
|
|
5
|
+
* log tail, and "what's next" section after a feature loop completes.
|
|
6
|
+
*/
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import type { RunSummary } from '../screens/RunScreen.js';
|
|
9
|
+
/**
|
|
10
|
+
* Props for RunCompletionSummary component
|
|
11
|
+
*/
|
|
12
|
+
export interface RunCompletionSummaryProps {
|
|
13
|
+
/** Run summary data */
|
|
14
|
+
summary: RunSummary;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* RunCompletionSummary component
|
|
18
|
+
*
|
|
19
|
+
* Renders the run loop completion recap inline within the
|
|
20
|
+
* RunScreen content area.
|
|
21
|
+
*/
|
|
22
|
+
export declare function RunCompletionSummary({ summary, }: RunCompletionSummaryProps): React.ReactElement;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { StatusLine } from './StatusLine.js';
|
|
4
|
+
import { colors, theme } from '../theme.js';
|
|
5
|
+
import { formatNumber } from '../utils/loop-status.js';
|
|
6
|
+
/**
|
|
7
|
+
* RunCompletionSummary component
|
|
8
|
+
*
|
|
9
|
+
* Renders the run loop completion recap inline within the
|
|
10
|
+
* RunScreen content area.
|
|
11
|
+
*/
|
|
12
|
+
export function RunCompletionSummary({ summary, }) {
|
|
13
|
+
const totalTokens = summary.tokensInput + summary.tokensOutput;
|
|
14
|
+
const stoppedCodes = new Set([130, 143]);
|
|
15
|
+
const exitState = summary.exitCode === 0
|
|
16
|
+
? { label: 'Complete', color: colors.green, message: 'Done. Feature loop completed successfully.' }
|
|
17
|
+
: stoppedCodes.has(summary.exitCode)
|
|
18
|
+
? { label: 'Stopped', color: colors.orange, message: 'Stopped. Feature loop interrupted.' }
|
|
19
|
+
: summary.exitCodeInferred
|
|
20
|
+
? { label: 'Unknown', color: colors.orange, message: `Done. Exit status uncertain (inferred code ${summary.exitCode}). Check logs for details.` }
|
|
21
|
+
: { label: 'Failed', color: colors.pink, message: `Done. Feature loop exited with code ${summary.exitCode}.` };
|
|
22
|
+
return (_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: theme.chars.prompt }), _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: theme.chars.prompt }), _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: theme.chars.prompt }), _jsx(Text, { color: colors.blue, children: "/help" }), _jsx(Text, { dimColor: true, children: "See all commands" })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter or Esc to return to shell" }) })] }));
|
|
23
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpecCompletionSummary - Displays spec generation recap
|
|
3
|
+
*
|
|
4
|
+
* Shows the goal, key decisions, file preview, and "what's next"
|
|
5
|
+
* section after a spec has been generated. Extracts a recap from
|
|
6
|
+
* the conversation history using heuristic text analysis.
|
|
7
|
+
*/
|
|
8
|
+
import React from 'react';
|
|
9
|
+
import type { Message } from './MessageList.js';
|
|
10
|
+
/**
|
|
11
|
+
* Props for the SpecCompletionSummary component
|
|
12
|
+
*/
|
|
13
|
+
export interface SpecCompletionSummaryProps {
|
|
14
|
+
/** Name of the feature */
|
|
15
|
+
featureName: string;
|
|
16
|
+
/** Generated spec content */
|
|
17
|
+
spec: string;
|
|
18
|
+
/** Path where spec was saved */
|
|
19
|
+
specPath: string;
|
|
20
|
+
/** Conversation messages from the interview */
|
|
21
|
+
messages: Message[];
|
|
22
|
+
}
|
|
23
|
+
/** Strip filler prefixes ('you want', 'understood', 'got it') from AI recap text and capitalize. */
|
|
24
|
+
export declare function normalizeRecap(text: string): string;
|
|
25
|
+
/** Strip user speech filler and normalize decision text: add trailing period if missing, capitalize. */
|
|
26
|
+
export declare function normalizeUserDecision(text: string): string;
|
|
27
|
+
/** Truncate text to max characters with ellipsis. */
|
|
28
|
+
export declare function summarizeText(text: string, max?: number): string;
|
|
29
|
+
/** Return true if the decision string is substantive enough to display (>= 8 chars and >= 3 words). */
|
|
30
|
+
export declare function isUsefulDecision(entry: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Extract goal and key decisions from conversation messages.
|
|
33
|
+
*
|
|
34
|
+
* @returns `goalCandidate` — a one-line summary of the feature goal, and
|
|
35
|
+
* `decisions` — up to 4 key decisions extracted from the conversation.
|
|
36
|
+
*/
|
|
37
|
+
export declare function extractRecap(messages: Message[], featureName: string): {
|
|
38
|
+
goalCandidate: string;
|
|
39
|
+
decisions: string[];
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* SpecCompletionSummary component
|
|
43
|
+
*
|
|
44
|
+
* Renders the spec generation completion recap inline within the
|
|
45
|
+
* InterviewScreen content area.
|
|
46
|
+
*/
|
|
47
|
+
export declare function SpecCompletionSummary({ featureName, spec, specPath, messages, }: SpecCompletionSummaryProps): React.ReactElement;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { StatusLine } from './StatusLine.js';
|
|
4
|
+
import { colors, theme } from '../theme.js';
|
|
5
|
+
import { PHASE_CONFIGS } from '../hooks/useSpecGenerator.js';
|
|
6
|
+
const MAX_RECAP_SOURCE_LENGTH = 1200;
|
|
7
|
+
/** Strip filler prefixes ('you want', 'understood', 'got it') from AI recap text and capitalize. */
|
|
8
|
+
export function normalizeRecap(text) {
|
|
9
|
+
let result = text.trim();
|
|
10
|
+
result = result.replace(/^[^a-z0-9]+/i, '');
|
|
11
|
+
result = result.replace(/^you want\s*/i, '');
|
|
12
|
+
result = result.replace(/^understood[:,]?\s*/i, '');
|
|
13
|
+
result = result.replace(/^got it[-\u2014:]*\s*/i, '');
|
|
14
|
+
return result.charAt(0).toUpperCase() + result.slice(1);
|
|
15
|
+
}
|
|
16
|
+
/** Strip user speech filler and normalize decision text: add trailing period if missing, capitalize. */
|
|
17
|
+
export function normalizeUserDecision(text) {
|
|
18
|
+
let result = text.trim();
|
|
19
|
+
result = result.replace(/^[^a-z0-9]+/i, '');
|
|
20
|
+
result = result.replace(/^i (?:would like|want|need|prefer|expect) to\s*/i, '');
|
|
21
|
+
result = result.replace(/^i (?:would like|want|need|prefer|expect)\s*/i, '');
|
|
22
|
+
result = result.replace(/^please\s*/i, '');
|
|
23
|
+
result = result.replace(/^up to you[:,]?\s*/i, '');
|
|
24
|
+
result = result.replace(/^both\s*/i, 'Both ');
|
|
25
|
+
if (result && !/[.!?]$/.test(result)) {
|
|
26
|
+
result += '.';
|
|
27
|
+
}
|
|
28
|
+
return result.charAt(0).toUpperCase() + result.slice(1);
|
|
29
|
+
}
|
|
30
|
+
/** Truncate text to max characters with ellipsis. */
|
|
31
|
+
export function summarizeText(text, max = 160) {
|
|
32
|
+
if (text.length <= max)
|
|
33
|
+
return text;
|
|
34
|
+
return `${text.slice(0, max - 1)}\u2026`;
|
|
35
|
+
}
|
|
36
|
+
/** Return true if the decision string is substantive enough to display (>= 8 chars and >= 3 words). */
|
|
37
|
+
export function isUsefulDecision(entry) {
|
|
38
|
+
const normalized = entry.trim().toLowerCase();
|
|
39
|
+
if (normalized.length < 8)
|
|
40
|
+
return false;
|
|
41
|
+
const wordCount = normalized.split(/\s+/).length;
|
|
42
|
+
if (wordCount < 3)
|
|
43
|
+
return false;
|
|
44
|
+
if (['yes', 'no', 'both', 'ok', 'okay'].includes(normalized))
|
|
45
|
+
return false;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Extract goal and key decisions from conversation messages.
|
|
50
|
+
*
|
|
51
|
+
* @returns `goalCandidate` — a one-line summary of the feature goal, and
|
|
52
|
+
* `decisions` — up to 4 key decisions extracted from the conversation.
|
|
53
|
+
*/
|
|
54
|
+
export function extractRecap(messages, featureName) {
|
|
55
|
+
const userMessages = messages
|
|
56
|
+
.filter((msg) => msg.role === 'user')
|
|
57
|
+
.map((msg) => msg.content.trim())
|
|
58
|
+
.filter((content) => content.length > 0 && content.length <= MAX_RECAP_SOURCE_LENGTH);
|
|
59
|
+
const nonUrlUserMessages = userMessages.filter((content) => !/^https?:\/\//i.test(content) && !/^www\./i.test(content));
|
|
60
|
+
const assistantParagraphs = messages
|
|
61
|
+
.filter((msg) => msg.role === 'assistant' && msg.content && msg.content.length <= MAX_RECAP_SOURCE_LENGTH)
|
|
62
|
+
.flatMap((msg) => msg.content.split('\n\n'))
|
|
63
|
+
.map((para) => para.replace(/\s+/g, ' ').trim())
|
|
64
|
+
.filter((para) => para.length > 0 && para.length <= 320);
|
|
65
|
+
const recapCandidates = assistantParagraphs
|
|
66
|
+
.map((para) => para.replace(/^[^a-z0-9]+/i, '').trim())
|
|
67
|
+
.filter((para) => /^(you want|understood|got it)/i.test(para))
|
|
68
|
+
.map((para) => para.split(/next question:/i)[0].trim())
|
|
69
|
+
.filter((para) => para.length > 0);
|
|
70
|
+
const goalCandidate = recapCandidates.length > 0
|
|
71
|
+
? normalizeRecap(recapCandidates[0])
|
|
72
|
+
: (nonUrlUserMessages.find((content) => content.length > 20)
|
|
73
|
+
? normalizeUserDecision(nonUrlUserMessages.find((content) => content.length > 20))
|
|
74
|
+
: (nonUrlUserMessages[0] ? normalizeUserDecision(nonUrlUserMessages[0]) : `Define "${featureName}"`));
|
|
75
|
+
const decisions = [];
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
if (recapCandidates.length > 1) {
|
|
78
|
+
for (let i = 1; i < recapCandidates.length; i += 1) {
|
|
79
|
+
const entry = normalizeRecap(recapCandidates[i]);
|
|
80
|
+
const normalized = entry.toLowerCase();
|
|
81
|
+
if (!isUsefulDecision(entry))
|
|
82
|
+
continue;
|
|
83
|
+
if (seen.has(normalized))
|
|
84
|
+
continue;
|
|
85
|
+
decisions.push(entry);
|
|
86
|
+
seen.add(normalized);
|
|
87
|
+
if (decisions.length >= 4)
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
for (let i = nonUrlUserMessages.length - 1; i >= 0; i -= 1) {
|
|
93
|
+
const entry = nonUrlUserMessages[i];
|
|
94
|
+
const normalized = entry.toLowerCase();
|
|
95
|
+
if (entry === goalCandidate)
|
|
96
|
+
continue;
|
|
97
|
+
if (!isUsefulDecision(entry))
|
|
98
|
+
continue;
|
|
99
|
+
if (entry.length > 160)
|
|
100
|
+
continue;
|
|
101
|
+
if (seen.has(normalized))
|
|
102
|
+
continue;
|
|
103
|
+
decisions.unshift(normalizeUserDecision(entry));
|
|
104
|
+
seen.add(normalized);
|
|
105
|
+
if (decisions.length >= 4)
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { goalCandidate, decisions };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* SpecCompletionSummary component
|
|
113
|
+
*
|
|
114
|
+
* Renders the spec generation completion recap inline within the
|
|
115
|
+
* InterviewScreen content area.
|
|
116
|
+
*/
|
|
117
|
+
export function SpecCompletionSummary({ featureName, spec, specPath, messages, }) {
|
|
118
|
+
const specLines = spec ? spec.split('\n') : [];
|
|
119
|
+
const totalLines = specLines.length;
|
|
120
|
+
const previewLines = specLines.slice(0, 5);
|
|
121
|
+
const remainingLines = Math.max(0, totalLines - 5);
|
|
122
|
+
const { goalCandidate, decisions } = extractRecap(messages, featureName);
|
|
123
|
+
return (_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: [theme.chars.lineEnd, " 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: theme.chars.prompt }), _jsx(Text, { dimColor: true, children: "Review the spec in your editor" })] }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Text, { color: colors.green, children: theme.chars.prompt }), _jsx(Text, { color: colors.blue, children: "/help" }), _jsx(Text, { dimColor: true, children: "See all commands" })] })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter or Esc to return to shell" }) })] }));
|
|
124
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TipsBar - Contextual hints bar
|
|
3
|
+
*
|
|
4
|
+
* Renders a single dimmed line with slash commands highlighted in blue.
|
|
5
|
+
*/
|
|
6
|
+
import React from 'react';
|
|
7
|
+
/**
|
|
8
|
+
* Props for TipsBar component
|
|
9
|
+
*/
|
|
10
|
+
export interface TipsBarProps {
|
|
11
|
+
/** Tip text - slash commands (e.g. /help) are auto-highlighted */
|
|
12
|
+
text: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* TipsBar component
|
|
16
|
+
*
|
|
17
|
+
* Displays contextual tips with slash commands highlighted in blue.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```tsx
|
|
21
|
+
* <TipsBar text="Tip: /new <feature> to create spec, /help for commands" />
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare function TipsBar({ text }: TipsBarProps): React.ReactElement;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { colors } from '../theme.js';
|
|
4
|
+
/**
|
|
5
|
+
* TipsBar component
|
|
6
|
+
*
|
|
7
|
+
* Displays contextual tips with slash commands highlighted in blue.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* <TipsBar text="Tip: /new <feature> to create spec, /help for commands" />
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export function TipsBar({ text }) {
|
|
15
|
+
// Split text on slash commands to highlight them
|
|
16
|
+
const parts = text.split(/(\/[a-zA-Z]+)/g);
|
|
17
|
+
return (_jsx(Box, { children: parts.map((part, i) => {
|
|
18
|
+
if (part.startsWith('/')) {
|
|
19
|
+
return _jsx(Text, { color: colors.blue, children: part }, i);
|
|
20
|
+
}
|
|
21
|
+
return _jsx(Text, { dimColor: true, children: part }, i);
|
|
22
|
+
}) }));
|
|
23
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { Text, Box } from 'ink';
|
|
2
|
+
import { Text, Box, useStdout } from 'ink';
|
|
3
3
|
import { colors } from '../theme.js';
|
|
4
4
|
/**
|
|
5
5
|
* ASCII art banner for Wiggum CLI
|
|
@@ -12,6 +12,8 @@ const BANNER = `██╗ ██╗██╗ ██████╗ ███
|
|
|
12
12
|
╚███╔███╔╝██║╚██████╔╝╚██████╔╝╚██████╔╝██║ ╚═╝ ██║
|
|
13
13
|
╚══╝╚══╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝
|
|
14
14
|
`;
|
|
15
|
+
/** Minimum terminal columns needed to display the banner without wrapping */
|
|
16
|
+
const BANNER_MIN_WIDTH = 55;
|
|
15
17
|
/**
|
|
16
18
|
* WiggumBanner component
|
|
17
19
|
*
|
|
@@ -25,8 +27,11 @@ const BANNER = `██╗ ██╗██╗ ██████╗ ███
|
|
|
25
27
|
* ```
|
|
26
28
|
*/
|
|
27
29
|
export function WiggumBanner({ color = colors.yellow, compact = false, }) {
|
|
28
|
-
|
|
30
|
+
const { stdout } = useStdout();
|
|
31
|
+
const columns = stdout?.columns ?? 80;
|
|
32
|
+
// Auto-compact when terminal is too narrow for the ASCII art
|
|
33
|
+
if (compact || columns < BANNER_MIN_WIDTH) {
|
|
29
34
|
return (_jsx(Box, { children: _jsx(Text, { color: color, bold: true, children: "WIGGUM CLI" }) }));
|
|
30
35
|
}
|
|
31
|
-
return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: color, children: BANNER }) }));
|
|
36
|
+
return (_jsx(Box, { flexDirection: "column", overflow: "hidden", children: _jsx(Text, { color: color, wrap: "truncate", children: BANNER }) }));
|
|
32
37
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useBackgroundRuns - Hook for tracking background run processes
|
|
3
|
+
*
|
|
4
|
+
* Manages a list of backgrounded feature loop runs, polling their
|
|
5
|
+
* status files periodically to track completion.
|
|
6
|
+
*/
|
|
7
|
+
import { type LoopStatus } from '../utils/loop-status.js';
|
|
8
|
+
/**
|
|
9
|
+
* A backgrounded feature loop run
|
|
10
|
+
*/
|
|
11
|
+
export interface BackgroundRun {
|
|
12
|
+
/** Feature name being implemented */
|
|
13
|
+
featureName: string;
|
|
14
|
+
/** Timestamp when backgrounded */
|
|
15
|
+
backgroundedAt: number;
|
|
16
|
+
/** Path to the log file */
|
|
17
|
+
logPath: string;
|
|
18
|
+
/** Last polled status */
|
|
19
|
+
lastStatus: LoopStatus;
|
|
20
|
+
/** Whether the run has completed */
|
|
21
|
+
completed: boolean;
|
|
22
|
+
/** If polling was stopped due to repeated failures, the error reason */
|
|
23
|
+
pollError?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Return type for useBackgroundRuns hook
|
|
27
|
+
*/
|
|
28
|
+
export interface UseBackgroundRunsReturn {
|
|
29
|
+
/** Current list of background runs */
|
|
30
|
+
runs: BackgroundRun[];
|
|
31
|
+
/** Add a feature to background tracking. Reads initial status and starts polling. */
|
|
32
|
+
background: (featureName: string) => void;
|
|
33
|
+
/** Remove a completed run from tracking */
|
|
34
|
+
dismiss: (featureName: string) => void;
|
|
35
|
+
/** Get a specific run by feature name */
|
|
36
|
+
getRun: (featureName: string) => BackgroundRun | undefined;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Hook to track background feature loop runs
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```tsx
|
|
43
|
+
* const { runs, background, dismiss, getRun } = useBackgroundRuns();
|
|
44
|
+
*
|
|
45
|
+
* // When user presses Esc on RunScreen
|
|
46
|
+
* background('my-feature');
|
|
47
|
+
*
|
|
48
|
+
* // Check if a feature is running in background
|
|
49
|
+
* const run = getRun('my-feature');
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export declare function useBackgroundRuns(): UseBackgroundRunsReturn;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useBackgroundRuns - Hook for tracking background run processes
|
|
3
|
+
*
|
|
4
|
+
* Manages a list of backgrounded feature loop runs, polling their
|
|
5
|
+
* status files periodically to track completion.
|
|
6
|
+
*/
|
|
7
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
8
|
+
import { readLoopStatus, getLoopLogPath } from '../utils/loop-status.js';
|
|
9
|
+
import { logger } from '../../utils/logger.js';
|
|
10
|
+
const POLL_INTERVAL_MS = 5000;
|
|
11
|
+
/**
|
|
12
|
+
* Hook to track background feature loop runs
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const { runs, background, dismiss, getRun } = useBackgroundRuns();
|
|
17
|
+
*
|
|
18
|
+
* // When user presses Esc on RunScreen
|
|
19
|
+
* background('my-feature');
|
|
20
|
+
*
|
|
21
|
+
* // Check if a feature is running in background
|
|
22
|
+
* const run = getRun('my-feature');
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export function useBackgroundRuns() {
|
|
26
|
+
const [runs, setRuns] = useState([]);
|
|
27
|
+
const pollTimers = useRef(new Map());
|
|
28
|
+
const startPolling = useCallback((featureName) => {
|
|
29
|
+
// Clear existing timer if any
|
|
30
|
+
const existing = pollTimers.current.get(featureName);
|
|
31
|
+
if (existing)
|
|
32
|
+
clearInterval(existing);
|
|
33
|
+
let consecutiveFailures = 0;
|
|
34
|
+
const MAX_FAILURES = 3;
|
|
35
|
+
const timer = setInterval(() => {
|
|
36
|
+
try {
|
|
37
|
+
const status = readLoopStatus(featureName);
|
|
38
|
+
consecutiveFailures = 0;
|
|
39
|
+
const nowCompleted = !status.running;
|
|
40
|
+
setRuns((prev) => prev.map((run) => {
|
|
41
|
+
if (run.featureName !== featureName)
|
|
42
|
+
return run;
|
|
43
|
+
return {
|
|
44
|
+
...run,
|
|
45
|
+
lastStatus: status,
|
|
46
|
+
completed: nowCompleted,
|
|
47
|
+
};
|
|
48
|
+
}));
|
|
49
|
+
// Stop polling once the run completes
|
|
50
|
+
if (nowCompleted) {
|
|
51
|
+
clearInterval(timer);
|
|
52
|
+
pollTimers.current.delete(featureName);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
consecutiveFailures++;
|
|
57
|
+
logger.error(`Failed to poll status for ${featureName} (attempt ${consecutiveFailures}): ${err instanceof Error ? err.message : String(err)}`);
|
|
58
|
+
if (consecutiveFailures >= MAX_FAILURES) {
|
|
59
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
60
|
+
logger.error(`Stopping polling for ${featureName} after ${consecutiveFailures} consecutive failures`);
|
|
61
|
+
clearInterval(timer);
|
|
62
|
+
pollTimers.current.delete(featureName);
|
|
63
|
+
setRuns((prev) => prev.map((run) => run.featureName === featureName
|
|
64
|
+
? { ...run, pollError: `Status polling failed: ${reason}` }
|
|
65
|
+
: run));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}, POLL_INTERVAL_MS);
|
|
69
|
+
pollTimers.current.set(featureName, timer);
|
|
70
|
+
}, []);
|
|
71
|
+
const background = useCallback((featureName) => {
|
|
72
|
+
let status;
|
|
73
|
+
try {
|
|
74
|
+
status = readLoopStatus(featureName);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
logger.error(`Failed to read initial status for ${featureName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
78
|
+
// Assume still running so polling can discover truth — if truly dead, polling will detect it
|
|
79
|
+
status = { running: true, iteration: 0, maxIterations: 0, phase: 'unknown', tokensInput: 0, tokensOutput: 0 };
|
|
80
|
+
}
|
|
81
|
+
const logPath = getLoopLogPath(featureName);
|
|
82
|
+
setRuns((prev) => {
|
|
83
|
+
// Don't add duplicates
|
|
84
|
+
if (prev.some((r) => r.featureName === featureName)) {
|
|
85
|
+
return prev;
|
|
86
|
+
}
|
|
87
|
+
return [...prev, {
|
|
88
|
+
featureName,
|
|
89
|
+
backgroundedAt: Date.now(),
|
|
90
|
+
logPath,
|
|
91
|
+
lastStatus: status,
|
|
92
|
+
completed: !status.running,
|
|
93
|
+
}];
|
|
94
|
+
});
|
|
95
|
+
if (status.running) {
|
|
96
|
+
startPolling(featureName);
|
|
97
|
+
}
|
|
98
|
+
}, [startPolling]);
|
|
99
|
+
const dismiss = useCallback((featureName) => {
|
|
100
|
+
// Clear poll timer
|
|
101
|
+
const timer = pollTimers.current.get(featureName);
|
|
102
|
+
if (timer) {
|
|
103
|
+
clearInterval(timer);
|
|
104
|
+
pollTimers.current.delete(featureName);
|
|
105
|
+
}
|
|
106
|
+
setRuns((prev) => prev.filter((r) => r.featureName !== featureName));
|
|
107
|
+
}, []);
|
|
108
|
+
const getRun = useCallback((featureName) => {
|
|
109
|
+
return runs.find((r) => r.featureName === featureName);
|
|
110
|
+
}, [runs]);
|
|
111
|
+
// Cleanup all poll timers on unmount
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
return () => {
|
|
114
|
+
for (const timer of pollTimers.current.values()) {
|
|
115
|
+
clearInterval(timer);
|
|
116
|
+
}
|
|
117
|
+
pollTimers.current.clear();
|
|
118
|
+
};
|
|
119
|
+
}, []);
|
|
120
|
+
return { runs, background, dismiss, getRun };
|
|
121
|
+
}
|
|
@@ -385,7 +385,7 @@ export class InterviewOrchestrator {
|
|
|
385
385
|
try {
|
|
386
386
|
// Set initial phase
|
|
387
387
|
this.onPhaseChange('context');
|
|
388
|
-
this.onMessage('system', `
|
|
388
|
+
this.onMessage('system', `Phase 1: Context - New spec for feature: ${this.featureName}`);
|
|
389
389
|
// Ready for context input
|
|
390
390
|
this.onReady();
|
|
391
391
|
}
|