wiggum-cli 0.14.0 → 0.15.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/README.md +24 -5
- package/dist/ai/providers.js +19 -14
- package/dist/commands/run.d.ts +1 -1
- package/dist/commands/run.js +2 -2
- package/dist/index.js +7 -1
- package/dist/repl/session-state.d.ts +2 -0
- package/dist/templates/config/ralph.config.cjs.tmpl +1 -1
- package/dist/templates/prompts/PROMPT_review_auto.md.tmpl +7 -41
- package/dist/templates/prompts/PROMPT_review_merge.md.tmpl +163 -0
- package/dist/templates/scripts/feature-loop-actions.test.ts +92 -0
- package/dist/templates/scripts/feature-loop.sh.tmpl +157 -7
- package/dist/tui/app.js +20 -2
- package/dist/tui/components/ChatInput.d.ts +3 -1
- package/dist/tui/components/ChatInput.js +23 -4
- package/dist/tui/components/CommandDropdown.d.ts +3 -1
- package/dist/tui/components/CommandDropdown.js +10 -7
- package/dist/tui/components/SpecCompletionSummary.d.ts +3 -2
- package/dist/tui/components/SpecCompletionSummary.js +26 -9
- package/dist/tui/components/SummaryBox.d.ts +0 -3
- package/dist/tui/components/SummaryBox.js +4 -2
- package/dist/tui/orchestration/interview-orchestrator.js +35 -5
- package/dist/tui/screens/MainShell.js +2 -1
- package/dist/tui/screens/RunScreen.js +81 -12
- package/dist/tui/utils/action-inbox.d.ts +43 -0
- package/dist/tui/utils/action-inbox.js +109 -0
- package/dist/tui/utils/polishGoal.d.ts +37 -0
- package/dist/tui/utils/polishGoal.js +170 -0
- package/dist/utils/config.d.ts +1 -1
- package/dist/utils/fuzzy-match.d.ts +5 -0
- package/dist/utils/fuzzy-match.js +16 -0
- package/dist/utils/spec-names.d.ts +6 -0
- package/dist/utils/spec-names.js +23 -0
- package/package.json +9 -4
- package/src/templates/config/ralph.config.cjs.tmpl +1 -1
- package/src/templates/prompts/PROMPT_review_auto.md.tmpl +7 -41
- package/src/templates/prompts/PROMPT_review_merge.md.tmpl +163 -0
- package/src/templates/scripts/feature-loop-actions.test.ts +92 -0
- package/src/templates/scripts/feature-loop.sh.tmpl +157 -7
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
# Options:
|
|
7
7
|
# --worktree Use git worktree for isolation (enables parallel execution)
|
|
8
8
|
# --resume Resume an interrupted loop (reuses existing branch/worktree)
|
|
9
|
-
# --model MODEL Claude model to use (e.g., opus, sonnet, claude-sonnet-4-5-
|
|
10
|
-
# --review-mode MODE Review mode: 'manual' (stop at PR) or '
|
|
9
|
+
# --model MODEL Claude model to use (e.g., opus, sonnet, claude-sonnet-4-5-20250929)
|
|
10
|
+
# --review-mode MODE Review mode: 'manual' (stop at PR), 'auto' (review, no merge), or 'merge' (review + merge). Default: 'manual'
|
|
11
11
|
|
|
12
12
|
set -e
|
|
13
13
|
set -o pipefail
|
|
@@ -91,8 +91,8 @@ if [ -z "$REVIEW_MODE" ]; then
|
|
|
91
91
|
fi
|
|
92
92
|
|
|
93
93
|
# Validate review mode
|
|
94
|
-
if [ "$REVIEW_MODE" != "manual" ] && [ "$REVIEW_MODE" != "auto" ]; then
|
|
95
|
-
echo "ERROR: Invalid review mode: '$REVIEW_MODE'. Allowed values are 'manual' or '
|
|
94
|
+
if [ "$REVIEW_MODE" != "manual" ] && [ "$REVIEW_MODE" != "auto" ] && [ "$REVIEW_MODE" != "merge" ]; then
|
|
95
|
+
echo "ERROR: Invalid review mode: '$REVIEW_MODE'. Allowed values are 'manual', 'auto', or 'merge'." >&2
|
|
96
96
|
exit 1
|
|
97
97
|
fi
|
|
98
98
|
|
|
@@ -138,6 +138,68 @@ parse_and_accumulate_tokens() {
|
|
|
138
138
|
echo "${new_input}|${new_output}" > "$TOKENS_FILE"
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
# Action inbox: write request file if not already present
|
|
142
|
+
write_action_request() {
|
|
143
|
+
local action_file="/tmp/ralph-loop-${FEATURE}.action.json"
|
|
144
|
+
if [ -f "$action_file" ]; then
|
|
145
|
+
echo "WARNING: Action request file already exists, skipping write: $action_file" >&2
|
|
146
|
+
return 0
|
|
147
|
+
fi
|
|
148
|
+
cat > "$action_file" << 'EOF'
|
|
149
|
+
{
|
|
150
|
+
"id": "post_pr_choice",
|
|
151
|
+
"prompt": "Loop complete. What would you like to do?",
|
|
152
|
+
"choices": [
|
|
153
|
+
{"id": "done", "label": "Done — end loop"},
|
|
154
|
+
{"id": "merge_local", "label": "Merge back to main locally"},
|
|
155
|
+
{"id": "keep_branch", "label": "Keep branch as-is"},
|
|
156
|
+
{"id": "discard", "label": "Discard this work"}
|
|
157
|
+
],
|
|
158
|
+
"default": "done"
|
|
159
|
+
}
|
|
160
|
+
EOF
|
|
161
|
+
echo "Action request written: $action_file"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# Action inbox: poll for reply file, fallback to default after 15 minutes
|
|
165
|
+
poll_action_reply() {
|
|
166
|
+
local action_file="/tmp/ralph-loop-${FEATURE}.action.json"
|
|
167
|
+
local reply_file="/tmp/ralph-loop-${FEATURE}.action.reply.json"
|
|
168
|
+
local default_choice="keep_branch"
|
|
169
|
+
local timeout=900 # 15 minutes in seconds
|
|
170
|
+
local elapsed=0
|
|
171
|
+
|
|
172
|
+
# Read default from action file if present
|
|
173
|
+
if [ -f "$action_file" ]; then
|
|
174
|
+
local parsed_default
|
|
175
|
+
parsed_default=$(node -e "try { const d=require('fs').readFileSync(process.argv[1],'utf8'); console.log(JSON.parse(d).default||'keep_branch'); } catch(e) { console.log('keep_branch'); }" "$action_file" 2>/dev/null || echo "keep_branch")
|
|
176
|
+
if [ -n "$parsed_default" ]; then
|
|
177
|
+
default_choice="$parsed_default"
|
|
178
|
+
fi
|
|
179
|
+
fi
|
|
180
|
+
|
|
181
|
+
while [ $elapsed -lt $timeout ]; do
|
|
182
|
+
if [ -f "$reply_file" ]; then
|
|
183
|
+
local choice
|
|
184
|
+
choice=$(node -e "try { const d=require('fs').readFileSync(process.argv[1],'utf8'); console.log(JSON.parse(d).choice||''); } catch(e) { console.log(''); }" "$reply_file" 2>/dev/null || echo "")
|
|
185
|
+
if [ -n "$choice" ]; then
|
|
186
|
+
echo "User selected: $choice" >&2
|
|
187
|
+
# Cleanup both files
|
|
188
|
+
rm -f "$action_file" "$reply_file" 2>/dev/null || true
|
|
189
|
+
echo "$choice"
|
|
190
|
+
return 0
|
|
191
|
+
fi
|
|
192
|
+
fi
|
|
193
|
+
sleep 1
|
|
194
|
+
elapsed=$((elapsed + 1))
|
|
195
|
+
done
|
|
196
|
+
|
|
197
|
+
# Timeout: use default
|
|
198
|
+
echo "Action reply timeout after ${timeout}s, using default: $default_choice" >&2
|
|
199
|
+
rm -f "$action_file" "$reply_file" 2>/dev/null || true
|
|
200
|
+
echo "$default_choice"
|
|
201
|
+
}
|
|
202
|
+
|
|
141
203
|
# Initialize tokens
|
|
142
204
|
init_tokens
|
|
143
205
|
|
|
@@ -343,18 +405,106 @@ echo "======================== PR & REVIEW PHASE ========================"
|
|
|
343
405
|
write_phase_start "pr_review"
|
|
344
406
|
export FEATURE APP_DIR SPEC_DIR PROMPTS_DIR
|
|
345
407
|
PR_STATUS="success"
|
|
408
|
+
MAX_REVIEW_ATTEMPTS=3
|
|
409
|
+
|
|
346
410
|
if [ "$REVIEW_MODE" = "manual" ]; then
|
|
347
411
|
if ! cat "$PROMPTS_DIR/PROMPT_review_manual.md" | envsubst | $CLAUDE_CMD_OPUS 2>&1 | tee "$CLAUDE_OUTPUT"; then
|
|
348
412
|
PR_STATUS="failed"
|
|
349
413
|
fi
|
|
350
|
-
|
|
351
|
-
|
|
414
|
+
parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
|
|
415
|
+
|
|
416
|
+
elif [ "$REVIEW_MODE" = "merge" ]; then
|
|
417
|
+
# Merge mode: create PR, iterate review+fixes until approved, then merge
|
|
418
|
+
REVIEW_ATTEMPT=0
|
|
419
|
+
REVIEW_APPROVED=false
|
|
420
|
+
while [ $REVIEW_ATTEMPT -lt $MAX_REVIEW_ATTEMPTS ]; do
|
|
421
|
+
REVIEW_ATTEMPT=$((REVIEW_ATTEMPT + 1))
|
|
422
|
+
echo "--- Review attempt $REVIEW_ATTEMPT of $MAX_REVIEW_ATTEMPTS ---"
|
|
423
|
+
cat "$PROMPTS_DIR/PROMPT_review_merge.md" | envsubst | $CLAUDE_CMD_OPUS 2>&1 | tee "$CLAUDE_OUTPUT" || true
|
|
424
|
+
parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
|
|
425
|
+
|
|
426
|
+
# Check if output contains APPROVED
|
|
427
|
+
if grep -qi "APPROVED" "$CLAUDE_OUTPUT" 2>/dev/null; then
|
|
428
|
+
echo "Review approved!"
|
|
429
|
+
REVIEW_APPROVED=true
|
|
430
|
+
break
|
|
431
|
+
fi
|
|
432
|
+
|
|
433
|
+
if [ $REVIEW_ATTEMPT -lt $MAX_REVIEW_ATTEMPTS ]; then
|
|
434
|
+
echo "Review found issues. Running fix iteration..."
|
|
435
|
+
echo "Fix the issues found in the code review above. Run git diff main to see the current changes, then:
|
|
436
|
+
1. Fix each issue referenced in the review
|
|
437
|
+
2. Run tests: npm test
|
|
438
|
+
3. Commit and push the fixes
|
|
439
|
+
Do NOT propose completion options or ask interactive questions. Just fix, test, commit, push." | $CLAUDE_CMD_IMPL 2>&1 | tee "$CLAUDE_OUTPUT" || true
|
|
440
|
+
parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
|
|
441
|
+
fi
|
|
442
|
+
done
|
|
443
|
+
if [ "$REVIEW_APPROVED" != true ]; then
|
|
444
|
+
echo "Review not approved after $MAX_REVIEW_ATTEMPTS attempts."
|
|
352
445
|
PR_STATUS="failed"
|
|
353
446
|
fi
|
|
447
|
+
|
|
448
|
+
else
|
|
449
|
+
# Auto mode: create PR, iterate review+fixes until approved (no merge)
|
|
450
|
+
REVIEW_ATTEMPT=0
|
|
451
|
+
REVIEW_APPROVED=false
|
|
452
|
+
while [ $REVIEW_ATTEMPT -lt $MAX_REVIEW_ATTEMPTS ]; do
|
|
453
|
+
REVIEW_ATTEMPT=$((REVIEW_ATTEMPT + 1))
|
|
454
|
+
echo "--- Review attempt $REVIEW_ATTEMPT of $MAX_REVIEW_ATTEMPTS ---"
|
|
455
|
+
cat "$PROMPTS_DIR/PROMPT_review_auto.md" | envsubst | $CLAUDE_CMD_OPUS 2>&1 | tee "$CLAUDE_OUTPUT" || true
|
|
456
|
+
parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
|
|
457
|
+
|
|
458
|
+
# Check if output contains APPROVED
|
|
459
|
+
if grep -qi "APPROVED" "$CLAUDE_OUTPUT" 2>/dev/null; then
|
|
460
|
+
echo "Review approved!"
|
|
461
|
+
REVIEW_APPROVED=true
|
|
462
|
+
break
|
|
463
|
+
fi
|
|
464
|
+
|
|
465
|
+
if [ $REVIEW_ATTEMPT -lt $MAX_REVIEW_ATTEMPTS ]; then
|
|
466
|
+
echo "Review found issues. Running fix iteration..."
|
|
467
|
+
echo "Fix the issues found in the code review above. Run git diff main to see the current changes, then:
|
|
468
|
+
1. Fix each issue referenced in the review
|
|
469
|
+
2. Run tests: npm test
|
|
470
|
+
3. Commit and push the fixes
|
|
471
|
+
Do NOT propose completion options or ask interactive questions. Just fix, test, commit, push." | $CLAUDE_CMD_IMPL 2>&1 | tee "$CLAUDE_OUTPUT" || true
|
|
472
|
+
parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
|
|
473
|
+
fi
|
|
474
|
+
done
|
|
475
|
+
if [ "$REVIEW_APPROVED" != true ]; then
|
|
476
|
+
echo "Review not approved after $MAX_REVIEW_ATTEMPTS attempts. PR ready for manual review."
|
|
477
|
+
fi
|
|
354
478
|
fi
|
|
355
|
-
parse_and_accumulate_tokens "$CLAUDE_OUTPUT"
|
|
356
479
|
write_phase_end "pr_review" "$PR_STATUS"
|
|
357
480
|
|
|
481
|
+
# Phase 7.5: Post-completion action request
|
|
482
|
+
echo "======================== ACTION REQUEST PHASE ========================"
|
|
483
|
+
write_action_request
|
|
484
|
+
CHOSEN_ACTION=$(poll_action_reply)
|
|
485
|
+
echo "User chose: $CHOSEN_ACTION"
|
|
486
|
+
|
|
487
|
+
# Dispatch based on user choice
|
|
488
|
+
case "$CHOSEN_ACTION" in
|
|
489
|
+
done)
|
|
490
|
+
echo "Loop complete. Exiting."
|
|
491
|
+
;;
|
|
492
|
+
merge_local)
|
|
493
|
+
echo "Merging back to main locally..."
|
|
494
|
+
git checkout main 2>/dev/null || git checkout master
|
|
495
|
+
git merge --squash "$BRANCH" && git commit -m "feat($FEATURE): squash merge from $BRANCH"
|
|
496
|
+
echo "Merged. You can delete the branch with: git branch -D $BRANCH"
|
|
497
|
+
;;
|
|
498
|
+
discard)
|
|
499
|
+
echo "Discarding work on branch $BRANCH..."
|
|
500
|
+
git checkout main 2>/dev/null || git checkout master
|
|
501
|
+
git branch -D "$BRANCH" 2>/dev/null || echo "Branch $BRANCH not found locally."
|
|
502
|
+
;;
|
|
503
|
+
keep_branch|*)
|
|
504
|
+
echo "Keeping branch $BRANCH as-is."
|
|
505
|
+
;;
|
|
506
|
+
esac
|
|
507
|
+
|
|
358
508
|
# Persist final status for TUI summaries
|
|
359
509
|
if ! echo "$ITERATION|$MAX_ITERATIONS|$(date +%s)|done" > "$FINAL_STATUS_FILE"; then
|
|
360
510
|
echo "WARNING: Failed to write final status file: $FINAL_STATUS_FILE" >&2
|
package/dist/tui/app.js
CHANGED
|
@@ -14,6 +14,7 @@ import { Box, Text, render, useStdout } from 'ink';
|
|
|
14
14
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { loadConfigWithDefaults } from '../utils/config.js';
|
|
17
|
+
import { listSpecNames } from '../utils/spec-names.js';
|
|
17
18
|
import { logger } from '../utils/logger.js';
|
|
18
19
|
import { InterviewScreen } from './screens/InterviewScreen.js';
|
|
19
20
|
import { InitScreen } from './screens/InitScreen.js';
|
|
@@ -65,6 +66,14 @@ interviewProps, onComplete, onExit, }) {
|
|
|
65
66
|
}
|
|
66
67
|
savedPath = join(specsDir, `${featureName}.md`);
|
|
67
68
|
writeFileSync(savedPath, spec, 'utf-8');
|
|
69
|
+
// Refresh spec name cache so the new spec shows in /run autocomplete
|
|
70
|
+
try {
|
|
71
|
+
const updatedSpecNames = await listSpecNames(specsDir);
|
|
72
|
+
setSessionState((prev) => ({ ...prev, specNames: updatedSpecNames }));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Non-critical: autocomplete will update on next restart
|
|
76
|
+
}
|
|
68
77
|
onComplete?.(savedPath);
|
|
69
78
|
}
|
|
70
79
|
catch (err) {
|
|
@@ -117,8 +126,17 @@ interviewProps, onComplete, onExit, }) {
|
|
|
117
126
|
/**
|
|
118
127
|
* Handle init completion - update state and navigate to shell
|
|
119
128
|
*/
|
|
120
|
-
const handleInitComplete = useCallback((newState, generatedFiles) => {
|
|
121
|
-
|
|
129
|
+
const handleInitComplete = useCallback(async (newState, generatedFiles) => {
|
|
130
|
+
// Refresh spec names after init (config may have changed)
|
|
131
|
+
let specNames = [];
|
|
132
|
+
try {
|
|
133
|
+
const specsDir = join(newState.projectRoot, newState.config?.paths.specs ?? '.ralph/specs');
|
|
134
|
+
specNames = await listSpecNames(specsDir);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Non-critical: autocomplete will work without spec names
|
|
138
|
+
}
|
|
139
|
+
setSessionState({ ...newState, specNames });
|
|
122
140
|
const fileCount = generatedFiles?.length ?? 0;
|
|
123
141
|
const msg = fileCount > 0
|
|
124
142
|
? `\u2713 Initialization complete. Generated ${fileCount} configuration file${fileCount === 1 ? '' : 's'}.`
|
|
@@ -30,6 +30,8 @@ export interface ChatInputProps {
|
|
|
30
30
|
commands?: Command[];
|
|
31
31
|
/** Called when a slash command is selected */
|
|
32
32
|
onCommand?: (command: string) => void;
|
|
33
|
+
/** Spec suggestions for /run argument autocomplete */
|
|
34
|
+
specSuggestions?: Command[];
|
|
33
35
|
}
|
|
34
36
|
/**
|
|
35
37
|
* ChatInput component
|
|
@@ -61,4 +63,4 @@ export interface ChatInputProps {
|
|
|
61
63
|
* // Renders: › Type your message...
|
|
62
64
|
* ```
|
|
63
65
|
*/
|
|
64
|
-
export declare function ChatInput({ onSubmit, placeholder, disabled, allowEmpty, commands, onCommand, }: ChatInputProps): React.ReactElement;
|
|
66
|
+
export declare function ChatInput({ onSubmit, placeholder, disabled, allowEmpty, commands, onCommand, specSuggestions, }: ChatInputProps): React.ReactElement;
|
|
@@ -19,6 +19,7 @@ import { theme } from '../theme.js';
|
|
|
19
19
|
import { CommandDropdown, DEFAULT_COMMANDS } from './CommandDropdown.js';
|
|
20
20
|
import { useCommandHistory } from '../hooks/useCommandHistory.js';
|
|
21
21
|
import { normalizePastedText, insertTextAtCursor, deleteCharBefore, deleteWordBefore, moveCursorByWordLeft, moveCursorByWordRight, } from '../utils/input-utils.js';
|
|
22
|
+
const RUN_PREFIX = '/run ';
|
|
22
23
|
/**
|
|
23
24
|
* ChatInput component
|
|
24
25
|
*
|
|
@@ -49,7 +50,7 @@ import { normalizePastedText, insertTextAtCursor, deleteCharBefore, deleteWordBe
|
|
|
49
50
|
* // Renders: › Type your message...
|
|
50
51
|
* ```
|
|
51
52
|
*/
|
|
52
|
-
export function ChatInput({ onSubmit, placeholder = 'Type your message...', disabled = false, allowEmpty = false, commands = DEFAULT_COMMANDS, onCommand, }) {
|
|
53
|
+
export function ChatInput({ onSubmit, placeholder = 'Type your message...', disabled = false, allowEmpty = false, commands = DEFAULT_COMMANDS, onCommand, specSuggestions, }) {
|
|
53
54
|
const [value, setValue] = useState('');
|
|
54
55
|
const [cursorOffset, setCursorOffset] = useState(0);
|
|
55
56
|
const [showDropdown, setShowDropdown] = useState(false);
|
|
@@ -61,6 +62,12 @@ export function ChatInput({ onSubmit, placeholder = 'Type your message...', disa
|
|
|
61
62
|
const hasSpace = value.includes(' ');
|
|
62
63
|
// Only filter on the command name part (before the first space)
|
|
63
64
|
const commandFilter = isSlashCommand ? value.slice(1).split(' ')[0] : '';
|
|
65
|
+
// Detect "/run " argument autocomplete mode
|
|
66
|
+
const isRunArgMode = specSuggestions !== undefined &&
|
|
67
|
+
specSuggestions.length > 0 &&
|
|
68
|
+
value.startsWith(RUN_PREFIX);
|
|
69
|
+
// The text the user has typed after "/run "
|
|
70
|
+
const runArgFilter = isRunArgMode ? value.slice(RUN_PREFIX.length) : '';
|
|
64
71
|
// Store draft input when starting history navigation
|
|
65
72
|
const draftRef = useRef('');
|
|
66
73
|
const clampCursor = useCallback((nextValue, nextCursor) => {
|
|
@@ -77,13 +84,17 @@ export function ChatInput({ onSubmit, placeholder = 'Type your message...', disa
|
|
|
77
84
|
if (!fromHistory) {
|
|
78
85
|
resetNavigation();
|
|
79
86
|
}
|
|
80
|
-
|
|
87
|
+
const isCommandMode = nextValue.startsWith('/') && !nextValue.includes(' ');
|
|
88
|
+
const isRunArgModeNext = specSuggestions !== undefined &&
|
|
89
|
+
specSuggestions.length > 0 &&
|
|
90
|
+
nextValue.startsWith('/run ');
|
|
91
|
+
if (isCommandMode || isRunArgModeNext) {
|
|
81
92
|
setShowDropdown(true);
|
|
82
93
|
}
|
|
83
94
|
else {
|
|
84
95
|
setShowDropdown(false);
|
|
85
96
|
}
|
|
86
|
-
}, [clampCursor, resetNavigation]);
|
|
97
|
+
}, [clampCursor, resetNavigation, specSuggestions]);
|
|
87
98
|
const handleEscapeSequence = useCallback((input) => {
|
|
88
99
|
const seq = input;
|
|
89
100
|
if (seq === '\u001bb' || seq === '\u001b[1;3D') {
|
|
@@ -254,6 +265,14 @@ export function ChatInput({ onSubmit, placeholder = 'Type your message...', disa
|
|
|
254
265
|
updateValue('', 0, true);
|
|
255
266
|
setShowDropdown(false);
|
|
256
267
|
}, [onCommand, onSubmit, addToHistory, updateValue]);
|
|
268
|
+
/**
|
|
269
|
+
* Handle spec selection from /run argument dropdown
|
|
270
|
+
*/
|
|
271
|
+
const handleSpecSelect = useCallback((specName) => {
|
|
272
|
+
const newValue = `/run ${specName}`;
|
|
273
|
+
updateValue(newValue, newValue.length, true);
|
|
274
|
+
setShowDropdown(false);
|
|
275
|
+
}, [updateValue]);
|
|
257
276
|
/**
|
|
258
277
|
* Handle dropdown cancel
|
|
259
278
|
*/
|
|
@@ -268,5 +287,5 @@ export function ChatInput({ onSubmit, placeholder = 'Type your message...', disa
|
|
|
268
287
|
const rightText = value.slice(cursorOffset);
|
|
269
288
|
const cursorChar = rightText ? rightText[0] : ' ';
|
|
270
289
|
const remainder = rightText ? rightText.slice(1) : '';
|
|
271
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: theme.colors.prompt, bold: true, children: [theme.chars.prompt, ' '] }), value.length === 0 ? (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { inverse: true, children: ' ' }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: leftText }), _jsx(Text, { inverse: true, children: cursorChar }), _jsx(Text, { children: remainder })] }))] }), showDropdown && isSlashCommand && !hasSpace && (_jsx(CommandDropdown, { commands: commands, filter: commandFilter, onSelect: handleCommandSelect, onCancel: handleDropdownCancel }))] }));
|
|
290
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: theme.colors.prompt, bold: true, children: [theme.chars.prompt, ' '] }), value.length === 0 ? (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { inverse: true, children: ' ' }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: leftText }), _jsx(Text, { inverse: true, children: cursorChar }), _jsx(Text, { children: remainder })] }))] }), showDropdown && isRunArgMode && specSuggestions && (_jsx(CommandDropdown, { commands: specSuggestions, filter: runArgFilter, onSelect: handleSpecSelect, onCancel: handleDropdownCancel, itemPrefix: "" })), showDropdown && isSlashCommand && !hasSpace && !isRunArgMode && (_jsx(CommandDropdown, { commands: commands, filter: commandFilter, onSelect: handleCommandSelect, onCancel: handleDropdownCancel }))] }));
|
|
272
291
|
}
|
|
@@ -27,6 +27,8 @@ export interface CommandDropdownProps {
|
|
|
27
27
|
onSelect: (command: string) => void;
|
|
28
28
|
/** Called when dropdown is dismissed (Escape) */
|
|
29
29
|
onCancel: () => void;
|
|
30
|
+
/** Prefix displayed before each item name (default: '/') */
|
|
31
|
+
itemPrefix?: string;
|
|
30
32
|
}
|
|
31
33
|
/**
|
|
32
34
|
* CommandDropdown component
|
|
@@ -47,7 +49,7 @@ export interface CommandDropdownProps {
|
|
|
47
49
|
* />
|
|
48
50
|
* ```
|
|
49
51
|
*/
|
|
50
|
-
export declare function CommandDropdown({ commands, filter, onSelect, onCancel, }: CommandDropdownProps): React.ReactElement;
|
|
52
|
+
export declare function CommandDropdown({ commands, filter, onSelect, onCancel, itemPrefix, }: CommandDropdownProps): React.ReactElement;
|
|
51
53
|
/**
|
|
52
54
|
* Default commands available in wiggum
|
|
53
55
|
*/
|
|
@@ -9,11 +9,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
9
9
|
import React, { useState } from 'react';
|
|
10
10
|
import { Box, Text, useInput } from 'ink';
|
|
11
11
|
import { colors, box } from '../theme.js';
|
|
12
|
+
import { fuzzyMatch } from '../../utils/fuzzy-match.js';
|
|
12
13
|
/**
|
|
13
14
|
* Calculate the max width needed for command names
|
|
14
15
|
*/
|
|
15
|
-
function getMaxCommandWidth(commands) {
|
|
16
|
-
|
|
16
|
+
function getMaxCommandWidth(commands, prefix) {
|
|
17
|
+
if (commands.length === 0)
|
|
18
|
+
return 0;
|
|
19
|
+
return Math.max(...commands.map((cmd) => cmd.name.length + prefix.length));
|
|
17
20
|
}
|
|
18
21
|
/**
|
|
19
22
|
* CommandDropdown component
|
|
@@ -34,12 +37,12 @@ function getMaxCommandWidth(commands) {
|
|
|
34
37
|
* />
|
|
35
38
|
* ```
|
|
36
39
|
*/
|
|
37
|
-
export function CommandDropdown({ commands, filter, onSelect, onCancel, }) {
|
|
40
|
+
export function CommandDropdown({ commands, filter, onSelect, onCancel, itemPrefix = '/', }) {
|
|
38
41
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
39
|
-
// Filter commands based on input
|
|
40
|
-
const filteredCommands = commands.filter((cmd) => cmd.name
|
|
42
|
+
// Filter commands based on input using fuzzy matching
|
|
43
|
+
const filteredCommands = commands.filter((cmd) => fuzzyMatch(filter, cmd.name));
|
|
41
44
|
// Calculate column widths for alignment
|
|
42
|
-
const maxCmdWidth = getMaxCommandWidth(filteredCommands);
|
|
45
|
+
const maxCmdWidth = getMaxCommandWidth(filteredCommands, itemPrefix);
|
|
43
46
|
// Handle keyboard input
|
|
44
47
|
useInput((input, key) => {
|
|
45
48
|
if (key.escape) {
|
|
@@ -72,7 +75,7 @@ export function CommandDropdown({ commands, filter, onSelect, onCancel, }) {
|
|
|
72
75
|
const bottomBorder = box.bottomLeft + box.horizontal.repeat(contentWidth) + box.bottomRight;
|
|
73
76
|
return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: topBorder }), filteredCommands.map((cmd, index) => {
|
|
74
77
|
const isSelected = index === selectedIndex;
|
|
75
|
-
const cmdText =
|
|
78
|
+
const cmdText = `${itemPrefix}${cmd.name}`;
|
|
76
79
|
const padding = ' '.repeat(Math.max(0, maxCmdWidth - cmdText.length + 2));
|
|
77
80
|
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: box.vertical }), _jsxs(Text, { backgroundColor: isSelected ? colors.yellow : undefined, color: isSelected ? colors.brown : colors.yellow, children: [' ', cmdText] }), _jsx(Text, { children: padding }), _jsx(Text, { backgroundColor: isSelected ? colors.yellow : undefined, color: isSelected ? colors.brown : undefined, dimColor: !isSelected, children: cmd.description }), _jsx(Text, { children: ' '.repeat(Math.max(0, contentWidth - cmdText.length - padding.length - cmd.description.length - 1)) }), _jsx(Text, { dimColor: true, children: box.vertical })] }, cmd.name));
|
|
78
81
|
}), _jsx(Text, { dimColor: true, children: bottomBorder })] }));
|
|
@@ -20,7 +20,7 @@ export interface SpecCompletionSummaryProps {
|
|
|
20
20
|
/** Conversation messages from the interview */
|
|
21
21
|
messages: Message[];
|
|
22
22
|
}
|
|
23
|
-
/** Strip filler prefixes
|
|
23
|
+
/** Strip filler prefixes from AI recap text and capitalize. */
|
|
24
24
|
export declare function normalizeRecap(text: string): string;
|
|
25
25
|
/** Strip user speech filler and normalize decision text: add trailing period if missing, capitalize. */
|
|
26
26
|
export declare function normalizeUserDecision(text: string): string;
|
|
@@ -31,7 +31,8 @@ export declare function isUsefulDecision(entry: string): boolean;
|
|
|
31
31
|
/**
|
|
32
32
|
* Extract goal and key decisions from conversation messages.
|
|
33
33
|
*
|
|
34
|
-
* @returns `goalCandidate` — a one-line summary of the feature goal
|
|
34
|
+
* @returns `goalCandidate` — a one-line summary of the feature goal (polished
|
|
35
|
+
* via selectGoalSource + polishGoalSentence), and
|
|
35
36
|
* `decisions` — up to 4 key decisions extracted from the conversation.
|
|
36
37
|
*/
|
|
37
38
|
export declare function extractRecap(messages: Message[], featureName: string): {
|
|
@@ -3,14 +3,26 @@ import { Box, Text } from 'ink';
|
|
|
3
3
|
import { StatusLine } from './StatusLine.js';
|
|
4
4
|
import { colors, theme } from '../theme.js';
|
|
5
5
|
import { PHASE_CONFIGS } from '../hooks/useSpecGenerator.js';
|
|
6
|
+
import { selectGoalSource, polishGoalSentence } from '../utils/polishGoal.js';
|
|
6
7
|
const MAX_RECAP_SOURCE_LENGTH = 1200;
|
|
7
|
-
/** Strip filler prefixes
|
|
8
|
+
/** Strip filler prefixes from AI recap text and capitalize. */
|
|
8
9
|
export function normalizeRecap(text) {
|
|
9
10
|
let result = text.trim();
|
|
10
11
|
result = result.replace(/^[^a-z0-9]+/i, '');
|
|
11
12
|
result = result.replace(/^you want\s*/i, '');
|
|
13
|
+
result = result.replace(/^you're\s+\w+ing\s+to\s*/i, '');
|
|
14
|
+
result = result.replace(/^you'd like to\s*/i, '');
|
|
15
|
+
result = result.replace(/^you need to\s*/i, '');
|
|
16
|
+
result = result.replace(/^you would like to\s*/i, '');
|
|
12
17
|
result = result.replace(/^understood[:,]?\s*/i, '');
|
|
13
18
|
result = result.replace(/^got it[-\u2014:]*\s*/i, '');
|
|
19
|
+
result = result.replace(/^i understand\s*(that\s*)?/i, '');
|
|
20
|
+
result = result.replace(/^so you\s*/i, '');
|
|
21
|
+
result = result.replace(/^to summarize[:,]?\s*/i, '');
|
|
22
|
+
result = result.replace(/^in summary[:,]?\s*/i, '');
|
|
23
|
+
result = result.replace(/^here'?s what I understand[:,]?\s*/i, '');
|
|
24
|
+
if (!result)
|
|
25
|
+
return text.trim();
|
|
14
26
|
return result.charAt(0).toUpperCase() + result.slice(1);
|
|
15
27
|
}
|
|
16
28
|
/** Strip user speech filler and normalize decision text: add trailing period if missing, capitalize. */
|
|
@@ -48,7 +60,8 @@ export function isUsefulDecision(entry) {
|
|
|
48
60
|
/**
|
|
49
61
|
* Extract goal and key decisions from conversation messages.
|
|
50
62
|
*
|
|
51
|
-
* @returns `goalCandidate` — a one-line summary of the feature goal
|
|
63
|
+
* @returns `goalCandidate` — a one-line summary of the feature goal (polished
|
|
64
|
+
* via selectGoalSource + polishGoalSentence), and
|
|
52
65
|
* `decisions` — up to 4 key decisions extracted from the conversation.
|
|
53
66
|
*/
|
|
54
67
|
export function extractRecap(messages, featureName) {
|
|
@@ -64,14 +77,18 @@ export function extractRecap(messages, featureName) {
|
|
|
64
77
|
.filter((para) => para.length > 0 && para.length <= 320);
|
|
65
78
|
const recapCandidates = assistantParagraphs
|
|
66
79
|
.map((para) => para.replace(/^[^a-z0-9]+/i, '').trim())
|
|
67
|
-
.filter((para) => /^(you want|understood|got it)/i.test(para))
|
|
80
|
+
.filter((para) => /^(you want|you're\s+\w+ing\s+to|you'd like|you need|you would like|understood|got it|i understand|so you|to summarize|in summary|here'?s what)/i.test(para))
|
|
68
81
|
.map((para) => para.split(/next question:/i)[0].trim())
|
|
69
82
|
.filter((para) => para.length > 0);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
83
|
+
// Build structured inputs for the goal-source selector
|
|
84
|
+
const aiRecap = recapCandidates.length > 0 ? normalizeRecap(recapCandidates[0]) : '';
|
|
85
|
+
const keyDecisions = recapCandidates.length > 1
|
|
86
|
+
? recapCandidates.slice(1).map((c) => normalizeRecap(c)).filter(isUsefulDecision)
|
|
87
|
+
: [];
|
|
88
|
+
const firstSubstantialUserMessage = nonUrlUserMessages.find((content) => content.length > 20) ?? nonUrlUserMessages[0] ?? `Define "${featureName}"`;
|
|
89
|
+
const userRequest = normalizeUserDecision(firstSubstantialUserMessage);
|
|
90
|
+
const { text: goalSourceText } = selectGoalSource({ aiRecap, keyDecisions, userRequest });
|
|
91
|
+
const goalCandidate = polishGoalSentence(goalSourceText);
|
|
75
92
|
const decisions = [];
|
|
76
93
|
const seen = new Set();
|
|
77
94
|
if (recapCandidates.length > 1) {
|
|
@@ -120,5 +137,5 @@ export function SpecCompletionSummary({ featureName, spec, specPath, messages, }
|
|
|
120
137
|
const previewLines = specLines.slice(0, 5);
|
|
121
138
|
const remainingLines = Math.max(0, totalLines - 5);
|
|
122
139
|
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: ",
|
|
140
|
+
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: ", 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
141
|
}
|
|
@@ -5,9 +5,6 @@
|
|
|
5
5
|
* terminal width. Provides section separators and content padding.
|
|
6
6
|
*/
|
|
7
7
|
import React from 'react';
|
|
8
|
-
/**
|
|
9
|
-
* Props for SummaryBox component
|
|
10
|
-
*/
|
|
11
8
|
export interface SummaryBoxProps {
|
|
12
9
|
/** Child content to render inside the box */
|
|
13
10
|
children: React.ReactNode;
|
|
@@ -8,6 +8,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
8
8
|
import React from 'react';
|
|
9
9
|
import { Box, Text, useStdout } from 'ink';
|
|
10
10
|
import { box, colors } from '../theme.js';
|
|
11
|
+
/** Maximum box width to prevent layout conflicts with other components */
|
|
12
|
+
const MAX_BOX_WIDTH = 80;
|
|
11
13
|
/**
|
|
12
14
|
* SummaryBox component
|
|
13
15
|
*
|
|
@@ -27,8 +29,8 @@ import { box, colors } from '../theme.js';
|
|
|
27
29
|
export function SummaryBox({ children, minWidth = 60, }) {
|
|
28
30
|
const { stdout } = useStdout();
|
|
29
31
|
const terminalWidth = stdout?.columns ?? 80;
|
|
30
|
-
// Use terminal width,
|
|
31
|
-
const boxWidth = Math.max(minWidth, terminalWidth);
|
|
32
|
+
// Use terminal width, clamped between minWidth and MAX_BOX_WIDTH
|
|
33
|
+
const boxWidth = Math.min(Math.max(minWidth, terminalWidth), MAX_BOX_WIDTH);
|
|
32
34
|
const contentWidth = boxWidth - 4; // Account for borders and padding
|
|
33
35
|
// Top border: ┌─────┐
|
|
34
36
|
const topBorder = box.topLeft + box.horizontal.repeat(boxWidth - 2) + box.topRight;
|
|
@@ -179,9 +179,27 @@ When generating the spec, use this format:
|
|
|
179
179
|
* @internal Exported for testing
|
|
180
180
|
*/
|
|
181
181
|
export function parseInterviewResponse(response) {
|
|
182
|
-
//
|
|
183
|
-
const
|
|
184
|
-
|
|
182
|
+
// Try multiple fenced block formats: ```options, ```json, or plain ```
|
|
183
|
+
const fencedPatterns = [
|
|
184
|
+
/```options\s*\n([\s\S]*?)\n```/,
|
|
185
|
+
/```json\s*\n(\[[\s\S]*?\])\n```/,
|
|
186
|
+
/```\s*\n(\[[\s\S]*?\])\n```/,
|
|
187
|
+
];
|
|
188
|
+
let match = null;
|
|
189
|
+
for (const pattern of fencedPatterns) {
|
|
190
|
+
match = response.match(pattern);
|
|
191
|
+
if (match)
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
// Last resort: look for a bare JSON array containing "id" and "label" keys
|
|
195
|
+
if (!match) {
|
|
196
|
+
const bareArrayRegex = /(\[\s*\n\s*\{[^]*?"id"[^]*?"label"[^]*?\}\s*\n\s*\])/;
|
|
197
|
+
const bareMatch = response.match(bareArrayRegex);
|
|
198
|
+
if (bareMatch) {
|
|
199
|
+
// Synthesize a match-like result with index
|
|
200
|
+
match = bareMatch;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
185
203
|
if (!match) {
|
|
186
204
|
return null;
|
|
187
205
|
}
|
|
@@ -477,7 +495,12 @@ Respond with a VERY brief (1-2 sentence) summary of what you found relevant to t
|
|
|
477
495
|
this.onWorkingChange(true, 'Formulating first question based on analysis...');
|
|
478
496
|
const interviewPrompt = `Based on what you learned about the project, briefly acknowledge the user's goals for "${this.featureName}" and ask your FIRST clarifying question.
|
|
479
497
|
Ask only ONE question. Be concise.`;
|
|
480
|
-
|
|
498
|
+
let response = await this.conversation.chat(interviewPrompt);
|
|
499
|
+
// If the AI responded with only tool calls and no question text, retry once
|
|
500
|
+
if (!response || !response.trim() || !parseInterviewResponse(response)) {
|
|
501
|
+
const retryPrompt = `You explored the project but didn't ask a question yet. Please ask your FIRST clarifying question about "${this.featureName}" now. Ask only ONE question. Include an options block.`;
|
|
502
|
+
response = await this.conversation.chat(retryPrompt);
|
|
503
|
+
}
|
|
481
504
|
this.emitParsedResponse(response);
|
|
482
505
|
// Transition to interview phase
|
|
483
506
|
this.phase = 'interview';
|
|
@@ -556,7 +579,14 @@ Ask only ONE question. Be concise.`;
|
|
|
556
579
|
else {
|
|
557
580
|
this.currentQuestion = null;
|
|
558
581
|
this.onQuestion?.(null);
|
|
559
|
-
|
|
582
|
+
// Strip any JSON-like option blocks from the displayed message to avoid
|
|
583
|
+
// showing raw JSON when structured parsing fails
|
|
584
|
+
const cleaned = response
|
|
585
|
+
.replace(/```(?:options|json)?\s*\n[\s\S]*?\n```/g, '')
|
|
586
|
+
.replace(/\[\s*\n\s*\{[^]*?"id"[^]*?"label"[^]*?\}\s*\n\s*\]/g, '')
|
|
587
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
588
|
+
.trim();
|
|
589
|
+
this.onMessage('assistant', cleaned || response);
|
|
560
590
|
}
|
|
561
591
|
}
|
|
562
592
|
/**
|
|
@@ -266,7 +266,8 @@ export function MainShell({ header, sessionState, onNavigate, backgroundRuns, in
|
|
|
266
266
|
const tips = sessionState.initialized
|
|
267
267
|
? 'Tip: /new <feature> to create spec, /help for commands'
|
|
268
268
|
: 'Tip: /init to set up, /help for commands';
|
|
269
|
-
const
|
|
269
|
+
const specSuggestions = useMemo(() => sessionState.specNames?.map((name) => ({ name, description: '' })), [sessionState.specNames]);
|
|
270
|
+
const inputElement = (_jsx(ChatInput, { onSubmit: handleSubmit, disabled: false, placeholder: "Enter command or type /help...", onCommand: (cmd) => handleSubmit(`/${cmd}`), specSuggestions: specSuggestions }));
|
|
270
271
|
return (_jsxs(AppShell, { header: header, tips: tips, isWorking: syncStatus === 'running', workingStatus: syncStatus === 'running' ? 'Syncing project context\u2026' : undefined, input: inputElement, footerStatus: {
|
|
271
272
|
action: projectLabel || 'Main Shell',
|
|
272
273
|
phase: sessionState.provider ? `${sessionState.provider}/${sessionState.model}` : 'No provider',
|