wiggum-cli 0.12.1 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/index.js +7 -6
  2. package/dist/templates/prompts/PROMPT_review_auto.md.tmpl +19 -23
  3. package/dist/tui/app.d.ts +12 -22
  4. package/dist/tui/app.js +130 -314
  5. package/dist/tui/components/AppShell.d.ts +47 -0
  6. package/dist/tui/components/AppShell.js +19 -0
  7. package/dist/tui/components/FooterStatusBar.js +2 -3
  8. package/dist/tui/components/HeaderContent.d.ts +28 -0
  9. package/dist/tui/components/HeaderContent.js +16 -0
  10. package/dist/tui/components/MessageList.d.ts +9 -7
  11. package/dist/tui/components/MessageList.js +23 -17
  12. package/dist/tui/components/RunCompletionSummary.d.ts +22 -0
  13. package/dist/tui/components/RunCompletionSummary.js +23 -0
  14. package/dist/tui/components/SpecCompletionSummary.d.ts +47 -0
  15. package/dist/tui/components/SpecCompletionSummary.js +124 -0
  16. package/dist/tui/components/TipsBar.d.ts +24 -0
  17. package/dist/tui/components/TipsBar.js +23 -0
  18. package/dist/tui/components/WiggumBanner.js +8 -3
  19. package/dist/tui/hooks/useBackgroundRuns.d.ts +52 -0
  20. package/dist/tui/hooks/useBackgroundRuns.js +121 -0
  21. package/dist/tui/orchestration/interview-orchestrator.js +1 -1
  22. package/dist/tui/screens/InitScreen.d.ts +13 -8
  23. package/dist/tui/screens/InitScreen.js +86 -87
  24. package/dist/tui/screens/InterviewScreen.d.ts +11 -8
  25. package/dist/tui/screens/InterviewScreen.js +145 -99
  26. package/dist/tui/screens/MainShell.d.ts +13 -12
  27. package/dist/tui/screens/MainShell.js +65 -69
  28. package/dist/tui/screens/RunScreen.d.ts +17 -1
  29. package/dist/tui/screens/RunScreen.js +235 -80
  30. package/dist/tui/screens/index.d.ts +0 -2
  31. package/dist/tui/screens/index.js +0 -1
  32. package/dist/tui/utils/loop-status.d.ts +22 -3
  33. package/dist/tui/utils/loop-status.js +65 -15
  34. package/package.json +5 -1
  35. package/src/templates/prompts/PROMPT_review_auto.md.tmpl +19 -23
  36. package/dist/tui/screens/WelcomeScreen.d.ts +0 -44
  37. package/dist/tui/screens/WelcomeScreen.js +0 -54
@@ -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: bullet with clean markdown-like styling
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 (for future scrolling support) */
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 blue
57
- * - Assistant messages: `● ` prefix in yellow, with inline tool cards
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: 'Hi! How can I help?' },
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
- * // Hi! How can I help?
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", marginY: 1, children: [_jsxs(Text, { color: theme.colors.prompt, bold: true, children: [theme.chars.prompt, ' '] }), _jsx(Text, { color: theme.colors.userText, children: content })] }));
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 (no prefix - distinguished by color)
14
- * Differentiates between thinking/context (solid LED, italic) and questions (bold with prefix)
15
- * Preserves original paragraph order while styling differently
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", marginY: 1, children: [toolCalls && toolCalls.length > 0 && (_jsx(Box, { flexDirection: "column", marginBottom: 1, 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) => {
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, { marginY: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Next question:" }), _jsx(Text, { color: theme.colors.aiText, children: para })] }, index));
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 - solid grey LED, italic dimmed text
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, { marginY: 1, flexDirection: "column", children: [_jsxs(Text, { color: theme.colors.brand, bold: true, children: ["Phase ", phaseNum, ": ", phaseName] }), _jsx(Text, { dimColor: true, children: description })] }));
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, { marginY: 1, children: _jsx(Text, { color: colors.pink, children: content }) }));
50
+ return (_jsx(Box, { children: _jsx(Text, { color: colors.pink, children: content }) }));
47
51
  }
48
52
  if (isSyncMessage) {
49
- return (_jsx(Box, { marginY: 1, children: _jsx(Text, { color: colors.blue, children: content }) }));
53
+ return (_jsx(Box, { children: _jsx(Text, { color: colors.blue, children: content }) }));
50
54
  }
51
- return (_jsx(Box, { marginY: 1, children: _jsx(Text, { dimColor: true, children: content }) }));
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 blue
58
- * - Assistant messages: `● ` prefix in yellow, with inline tool cards
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: 'Hi! How can I help?' },
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
- * // Hi! How can I help?
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
- if (compact) {
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', `Spec Generator initialized for feature: ${this.featureName}`);
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
  }