wiggum-cli 0.17.0 → 0.17.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/repl/command-parser.d.ts +5 -0
- package/dist/repl/command-parser.js +5 -0
- package/dist/tui/app.js +2 -1
- package/dist/tui/components/IssuePicker.js +24 -15
- package/dist/tui/components/StatusLine.d.ts +3 -1
- package/dist/tui/components/StatusLine.js +2 -2
- package/dist/tui/hooks/useAgentOrchestrator.d.ts +11 -0
- package/dist/tui/hooks/useAgentOrchestrator.js +47 -9
- package/dist/tui/screens/AgentScreen.d.ts +4 -1
- package/dist/tui/screens/AgentScreen.js +69 -19
- package/dist/tui/screens/MainShell.js +80 -4
- package/dist/tui/screens/RunScreen.js +34 -10
- package/dist/tui/utils/git-summary.d.ts +1 -0
- package/dist/tui/utils/git-summary.js +16 -0
- package/package.json +1 -1
|
@@ -54,6 +54,11 @@ export declare const REPL_COMMANDS: {
|
|
|
54
54
|
readonly usage: "/monitor <feature-name>";
|
|
55
55
|
readonly aliases: readonly ["m"];
|
|
56
56
|
};
|
|
57
|
+
readonly issue: {
|
|
58
|
+
readonly description: "Browse GitHub issues and start a new spec from one";
|
|
59
|
+
readonly usage: "/issue [search terms]";
|
|
60
|
+
readonly aliases: readonly [];
|
|
61
|
+
};
|
|
57
62
|
readonly agent: {
|
|
58
63
|
readonly description: "Start the autonomous backlog agent";
|
|
59
64
|
readonly usage: "/agent [--dry-run] [--max-items <n>]";
|
|
@@ -31,6 +31,11 @@ export const REPL_COMMANDS = {
|
|
|
31
31
|
usage: '/monitor <feature-name>',
|
|
32
32
|
aliases: ['m'],
|
|
33
33
|
},
|
|
34
|
+
issue: {
|
|
35
|
+
description: 'Browse GitHub issues and start a new spec from one',
|
|
36
|
+
usage: '/issue [search terms]',
|
|
37
|
+
aliases: [],
|
|
38
|
+
},
|
|
34
39
|
agent: {
|
|
35
40
|
description: 'Start the autonomous backlog agent',
|
|
36
41
|
usage: '/agent [--dry-run] [--max-items <n>]',
|
package/dist/tui/app.js
CHANGED
|
@@ -199,7 +199,8 @@ interviewProps, runProps, agentProps, onComplete, onExit, }) {
|
|
|
199
199
|
if (!featureName || typeof featureName !== 'string' || !sessionState.provider) {
|
|
200
200
|
return null; // useEffect will redirect to shell
|
|
201
201
|
}
|
|
202
|
-
return (_jsx(InterviewScreen, { header: headerElement, featureName: featureName, projectRoot: sessionState.projectRoot, provider: sessionState.provider, model: sessionState.model, scanResult: sessionState.scanResult, specsPath: sessionState.config?.paths.specs, initialReferences:
|
|
202
|
+
return (_jsx(InterviewScreen, { header: headerElement, featureName: featureName, projectRoot: sessionState.projectRoot, provider: sessionState.provider, model: sessionState.model, scanResult: sessionState.scanResult, specsPath: sessionState.config?.paths.specs, initialReferences: screenProps?.initialReferences
|
|
203
|
+
?? interviewProps?.initialReferences, onComplete: handleInterviewComplete, onCancel: handleInterviewCancel }));
|
|
203
204
|
}
|
|
204
205
|
case 'init':
|
|
205
206
|
return (_jsx(InitScreen, { header: headerElement, projectRoot: sessionState.projectRoot, sessionState: sessionState, onComplete: handleInitComplete, onCancel: () => navigate('shell') }));
|
|
@@ -16,14 +16,23 @@ function truncate(text, maxLen) {
|
|
|
16
16
|
return text;
|
|
17
17
|
return text.slice(0, maxLen - 1) + '\u2026';
|
|
18
18
|
}
|
|
19
|
+
function pad(text, width) {
|
|
20
|
+
if (text.length >= width)
|
|
21
|
+
return text.slice(0, width);
|
|
22
|
+
return text + ' '.repeat(width - text.length);
|
|
23
|
+
}
|
|
24
|
+
// Fixed column widths
|
|
25
|
+
const COL_NUMBER = 6; // " #104 "
|
|
26
|
+
const COL_STATE = 8; // " closed " or " open "
|
|
27
|
+
const COL_LABELS = 14; // " enhancement "
|
|
28
|
+
const COL_CHROME = 2; // left + right border
|
|
19
29
|
export function IssuePicker({ issues, repoSlug, onSelect, onCancel, isLoading, error, }) {
|
|
20
30
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
21
31
|
const { stdout } = useStdout();
|
|
22
|
-
const
|
|
32
|
+
const termColumns = stdout?.columns ?? 80;
|
|
23
33
|
React.useEffect(() => {
|
|
24
34
|
setSelectedIndex(0);
|
|
25
35
|
}, [issues]);
|
|
26
|
-
// Handle keyboard input
|
|
27
36
|
useInput((input, key) => {
|
|
28
37
|
if (key.escape) {
|
|
29
38
|
onCancel();
|
|
@@ -42,23 +51,23 @@ export function IssuePicker({ issues, repoSlug, onSelect, onCancel, isLoading, e
|
|
|
42
51
|
return;
|
|
43
52
|
}
|
|
44
53
|
});
|
|
45
|
-
// Build border strings
|
|
46
54
|
const countSuffix = !isLoading && !error ? ` (${issues.length})` : '';
|
|
47
55
|
const headerLabel = ` GitHub Issues ${box.horizontal} ${repoSlug}${countSuffix} `;
|
|
48
|
-
const contentWidth = Math.min(Math.max(60, headerLabel.length + 10),
|
|
56
|
+
const contentWidth = Math.min(Math.max(60, headerLabel.length + 10), termColumns - 6);
|
|
49
57
|
const topBorderFill = contentWidth - headerLabel.length - 1;
|
|
50
58
|
const topBorder = box.topLeft + box.horizontal + headerLabel + box.horizontal.repeat(Math.max(0, topBorderFill)) + box.topRight;
|
|
51
59
|
const bottomBorder = box.bottomLeft + box.horizontal.repeat(contentWidth) + box.bottomRight;
|
|
52
|
-
//
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
const chrome = 4; // borders + padding
|
|
56
|
-
const titleMaxLen = Math.max(20, contentWidth - numberWidth - labelBudget - chrome);
|
|
57
|
-
return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: topBorder }), isLoading && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: box.vertical }), _jsx(Text, { children: " " }), _jsx(Spinner, { type: "dots" }), _jsx(Text, { color: colors.yellow, children: " Searching..." }), _jsx(Text, { children: ' '.repeat(Math.max(0, contentWidth - 16)) }), _jsx(Text, { dimColor: true, children: box.vertical })] })), !isLoading && error && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: box.vertical }), _jsxs(Text, { color: colors.pink, children: [" ", error] }), _jsx(Text, { children: ' '.repeat(Math.max(0, contentWidth - error.length - 1)) }), _jsx(Text, { dimColor: true, children: box.vertical })] })), !isLoading && !error && issues.length === 0 && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: box.vertical }), _jsx(Text, { dimColor: true, children: " No open issues found" }), _jsx(Text, { children: ' '.repeat(Math.max(0, contentWidth - 21)) }), _jsx(Text, { dimColor: true, children: box.vertical })] })), !isLoading && !error && issues.map((issue, index) => {
|
|
60
|
+
// Title column gets all remaining space
|
|
61
|
+
const colTitle = Math.max(20, contentWidth - COL_NUMBER - COL_STATE - COL_LABELS - COL_CHROME);
|
|
62
|
+
return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: topBorder }), isLoading && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: box.vertical }), _jsx(Text, { children: " " }), _jsx(Spinner, { type: "dots" }), _jsx(Text, { color: colors.yellow, children: " Searching..." }), _jsx(Text, { children: ' '.repeat(Math.max(0, contentWidth - 16)) }), _jsx(Text, { dimColor: true, children: box.vertical })] })), !isLoading && error && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: box.vertical }), _jsx(Text, { color: colors.pink, children: pad(` ${error}`, contentWidth) }), _jsx(Text, { dimColor: true, children: box.vertical })] })), !isLoading && !error && issues.length === 0 && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: box.vertical }), _jsx(Text, { dimColor: true, children: pad(' No open issues found', contentWidth) }), _jsx(Text, { dimColor: true, children: box.vertical })] })), !isLoading && !error && issues.map((issue, index) => {
|
|
58
63
|
const isSelected = index === selectedIndex;
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
const bg = isSelected ? colors.yellow : undefined;
|
|
65
|
+
const fg = isSelected ? colors.brown : undefined;
|
|
66
|
+
const numberCell = pad(` #${issue.number}`, COL_NUMBER);
|
|
67
|
+
const titleCell = pad(` ${truncate(issue.title, colTitle - 1)}`, colTitle);
|
|
68
|
+
const stateCell = pad(` ${issue.state}`, COL_STATE);
|
|
69
|
+
const labelText = issue.labels.slice(0, MAX_LABELS).join(' ');
|
|
70
|
+
const labelCell = pad(` ${truncate(labelText, COL_LABELS - 1)}`, COL_LABELS);
|
|
71
|
+
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: box.vertical }), _jsx(Text, { backgroundColor: bg, color: isSelected ? colors.brown : colors.yellow, children: numberCell }), _jsx(Text, { backgroundColor: bg, color: fg, children: titleCell }), _jsx(Text, { backgroundColor: bg, color: isSelected ? colors.brown : issue.state === 'open' ? colors.green : colors.gray, children: stateCell }), _jsx(Text, { backgroundColor: bg, color: isSelected ? colors.brown : colors.gray, children: labelCell }), _jsx(Text, { dimColor: true, children: box.vertical })] }, issue.number));
|
|
72
|
+
}), _jsx(Text, { dimColor: true, children: bottomBorder }), _jsx(Box, { marginLeft: 1, children: _jsxs(Text, { color: colors.gray, children: ["(", '\u2191\u2193 navigate, Enter select, Esc cancel', ")"] }) })] }));
|
|
64
73
|
}
|
|
@@ -15,6 +15,8 @@ export interface StatusLineProps {
|
|
|
15
15
|
phase?: string;
|
|
16
16
|
/** Path or context info (e.g., working directory or feature name) */
|
|
17
17
|
path?: string;
|
|
18
|
+
/** Extra trailing segment (e.g., "review: auto") */
|
|
19
|
+
extra?: string;
|
|
18
20
|
}
|
|
19
21
|
/**
|
|
20
22
|
* StatusLine component
|
|
@@ -32,4 +34,4 @@ export interface StatusLineProps {
|
|
|
32
34
|
* // Renders: Initialize Project │ Analysis (4/5) │ /Users/name/project
|
|
33
35
|
* ```
|
|
34
36
|
*/
|
|
35
|
-
export declare function StatusLine({ action, phase, path, }: StatusLineProps): React.ReactElement;
|
|
37
|
+
export declare function StatusLine({ action, phase, path, extra, }: StatusLineProps): React.ReactElement;
|
|
@@ -17,11 +17,11 @@ import { theme, colors } from '../theme.js';
|
|
|
17
17
|
* // Renders: Initialize Project │ Analysis (4/5) │ /Users/name/project
|
|
18
18
|
* ```
|
|
19
19
|
*/
|
|
20
|
-
export function StatusLine({ action, phase, path, }) {
|
|
20
|
+
export function StatusLine({ action, phase, path, extra, }) {
|
|
21
21
|
const separator = theme.statusLine.separator;
|
|
22
22
|
// Truncate path if too long (keep last 40 chars)
|
|
23
23
|
const displayPath = path && path.length > 40
|
|
24
24
|
? '...' + path.slice(-37)
|
|
25
25
|
: path;
|
|
26
|
-
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: colors.yellow, bold: true, children: action }), phase && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: separator }), _jsx(Text, { children: phase })] })), displayPath && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: separator }), _jsx(Text, { dimColor: true, children: displayPath })] }))] }));
|
|
26
|
+
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: colors.yellow, bold: true, children: action }), phase && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: separator }), _jsx(Text, { children: phase })] })), displayPath && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: separator }), _jsx(Text, { dimColor: true, children: displayPath })] })), extra && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: separator }), _jsx(Text, { dimColor: true, children: extra })] }))] }));
|
|
27
27
|
}
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* Exposes an abort() function for clean shutdown on q/Esc.
|
|
8
8
|
*/
|
|
9
9
|
import type { AgentIssueState, AgentLogEntry, ReviewMode } from '../../agent/types.js';
|
|
10
|
+
import { type LoopStatus, type TaskCounts, type ActivityEvent } from '../utils/loop-status.js';
|
|
11
|
+
import { type CommitLogEntry } from '../utils/git-summary.js';
|
|
10
12
|
export type AgentStatus = 'idle' | 'running' | 'complete' | 'error';
|
|
11
13
|
export interface UseAgentOrchestratorOptions {
|
|
12
14
|
projectRoot: string;
|
|
@@ -17,12 +19,21 @@ export interface UseAgentOrchestratorOptions {
|
|
|
17
19
|
reviewMode?: ReviewMode;
|
|
18
20
|
dryRun?: boolean;
|
|
19
21
|
}
|
|
22
|
+
export interface LoopMonitorData {
|
|
23
|
+
loopStatus: LoopStatus;
|
|
24
|
+
tasks: TaskCounts;
|
|
25
|
+
branch: string;
|
|
26
|
+
recentCommits: CommitLogEntry[];
|
|
27
|
+
activityEvents: ActivityEvent[];
|
|
28
|
+
startTime: number;
|
|
29
|
+
}
|
|
20
30
|
export interface UseAgentOrchestratorResult {
|
|
21
31
|
status: AgentStatus;
|
|
22
32
|
activeIssue: AgentIssueState | null;
|
|
23
33
|
queue: AgentIssueState[];
|
|
24
34
|
completed: AgentIssueState[];
|
|
25
35
|
logEntries: AgentLogEntry[];
|
|
36
|
+
loopMonitor: LoopMonitorData | null;
|
|
26
37
|
error: string | null;
|
|
27
38
|
abort: () => void;
|
|
28
39
|
}
|
|
@@ -10,7 +10,8 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
|
|
10
10
|
import { resolveAgentEnv } from '../../agent/resolve-config.js';
|
|
11
11
|
import { createAgentOrchestrator, } from '../../agent/orchestrator.js';
|
|
12
12
|
import { initTracing, flushTracing } from '../../utils/tracing.js';
|
|
13
|
-
import { readCurrentPhase, readLoopStatus, parseLoopLog, parsePhaseChanges, getLoopLogPath, shouldSkipLine, } from '../utils/loop-status.js';
|
|
13
|
+
import { readCurrentPhase, readLoopStatus, parseLoopLog, parsePhaseChanges, parseImplementationPlan, getLoopLogPath, shouldSkipLine, getGitBranch, } from '../utils/loop-status.js';
|
|
14
|
+
import { getRecentCommits } from '../utils/git-summary.js';
|
|
14
15
|
const MAX_LOG_ENTRIES = 500;
|
|
15
16
|
function now() {
|
|
16
17
|
return new Date().toISOString();
|
|
@@ -245,6 +246,7 @@ export function useAgentOrchestrator(options) {
|
|
|
245
246
|
const [queue, setQueue] = useState([]);
|
|
246
247
|
const [completed, setCompleted] = useState([]);
|
|
247
248
|
const [logEntries, setLogEntries] = useState([]);
|
|
249
|
+
const [loopMonitor, setLoopMonitor] = useState(null);
|
|
248
250
|
const [error, setError] = useState(null);
|
|
249
251
|
const abortRef = useRef(null);
|
|
250
252
|
const startedRef = useRef(false);
|
|
@@ -258,24 +260,30 @@ export function useAgentOrchestrator(options) {
|
|
|
258
260
|
clearInterval(pollingRef.current.interval);
|
|
259
261
|
pollingRef.current = null;
|
|
260
262
|
}
|
|
263
|
+
setLoopMonitor(null);
|
|
261
264
|
}, []);
|
|
262
265
|
const startLoopPolling = useCallback((featureName) => {
|
|
263
266
|
stopLoopPolling(); // clear any existing polling
|
|
267
|
+
const startTime = Date.now();
|
|
264
268
|
const state = {
|
|
265
269
|
featureName,
|
|
266
270
|
interval: null,
|
|
267
271
|
lastLogTimestamp: undefined,
|
|
268
272
|
lastPhases: undefined,
|
|
269
273
|
};
|
|
270
|
-
|
|
274
|
+
// Accumulated activity events across polls
|
|
275
|
+
const activityEventsAcc = [];
|
|
276
|
+
const MAX_ACTIVITY = 50;
|
|
277
|
+
const poll = async () => {
|
|
271
278
|
// Read current phase
|
|
272
|
-
const
|
|
273
|
-
if (
|
|
274
|
-
setActiveIssue((prev) => prev ? { ...prev, loopPhase:
|
|
279
|
+
const currentPhase = readCurrentPhase(featureName);
|
|
280
|
+
if (currentPhase) {
|
|
281
|
+
setActiveIssue((prev) => prev ? { ...prev, loopPhase: currentPhase } : prev);
|
|
275
282
|
}
|
|
276
|
-
// Read loop status for iteration count
|
|
283
|
+
// Read loop status for iteration count + token data
|
|
284
|
+
let loopStatus = null;
|
|
277
285
|
try {
|
|
278
|
-
|
|
286
|
+
loopStatus = readLoopStatus(featureName);
|
|
279
287
|
if (loopStatus.iteration > 0) {
|
|
280
288
|
setActiveIssue((prev) => prev ? { ...prev, loopIterations: loopStatus.iteration } : prev);
|
|
281
289
|
}
|
|
@@ -283,11 +291,26 @@ export function useAgentOrchestrator(options) {
|
|
|
283
291
|
catch {
|
|
284
292
|
// Invalid feature name or file not ready — skip
|
|
285
293
|
}
|
|
294
|
+
// Read task progress
|
|
295
|
+
let tasks = { tasksDone: 0, tasksPending: 0, e2eDone: 0, e2ePending: 0, planExists: false };
|
|
296
|
+
try {
|
|
297
|
+
tasks = await parseImplementationPlan(options.projectRoot, featureName, '.ralph/specs');
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// Plan not yet available
|
|
301
|
+
}
|
|
302
|
+
// Git info
|
|
303
|
+
const branch = getGitBranch(options.projectRoot);
|
|
304
|
+
const recentCommits = getRecentCommits(options.projectRoot, 3);
|
|
286
305
|
// Parse loop log for new events
|
|
287
306
|
const logPath = getLoopLogPath(featureName);
|
|
288
307
|
const logEvents = parseLoopLog(logPath, state.lastLogTimestamp);
|
|
289
308
|
if (logEvents.length > 0) {
|
|
290
309
|
state.lastLogTimestamp = logEvents[logEvents.length - 1].timestamp + 1;
|
|
310
|
+
activityEventsAcc.push(...logEvents);
|
|
311
|
+
if (activityEventsAcc.length > MAX_ACTIVITY) {
|
|
312
|
+
activityEventsAcc.splice(0, activityEventsAcc.length - MAX_ACTIVITY);
|
|
313
|
+
}
|
|
291
314
|
setLogEntries((prev) => {
|
|
292
315
|
let next = prev;
|
|
293
316
|
for (const evt of logEvents) {
|
|
@@ -302,6 +325,10 @@ export function useAgentOrchestrator(options) {
|
|
|
302
325
|
state.lastPhases = phaseResult.currentPhases;
|
|
303
326
|
}
|
|
304
327
|
if (phaseResult.events.length > 0) {
|
|
328
|
+
activityEventsAcc.push(...phaseResult.events);
|
|
329
|
+
if (activityEventsAcc.length > MAX_ACTIVITY) {
|
|
330
|
+
activityEventsAcc.splice(0, activityEventsAcc.length - MAX_ACTIVITY);
|
|
331
|
+
}
|
|
305
332
|
setLogEntries((prev) => {
|
|
306
333
|
let next = prev;
|
|
307
334
|
for (const evt of phaseResult.events) {
|
|
@@ -310,12 +337,23 @@ export function useAgentOrchestrator(options) {
|
|
|
310
337
|
return next;
|
|
311
338
|
});
|
|
312
339
|
}
|
|
340
|
+
// Update loop monitor data
|
|
341
|
+
if (loopStatus) {
|
|
342
|
+
setLoopMonitor({
|
|
343
|
+
loopStatus,
|
|
344
|
+
tasks,
|
|
345
|
+
branch,
|
|
346
|
+
recentCommits,
|
|
347
|
+
activityEvents: [...activityEventsAcc],
|
|
348
|
+
startTime,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
313
351
|
};
|
|
314
352
|
state.interval = setInterval(poll, 3000);
|
|
315
353
|
pollingRef.current = state;
|
|
316
354
|
// Run first poll immediately
|
|
317
355
|
poll();
|
|
318
|
-
}, [stopLoopPolling]);
|
|
356
|
+
}, [stopLoopPolling, options.projectRoot]);
|
|
319
357
|
const abort = useCallback(() => {
|
|
320
358
|
abortRef.current?.abort();
|
|
321
359
|
stopLoopPolling();
|
|
@@ -449,5 +487,5 @@ export function useAgentOrchestrator(options) {
|
|
|
449
487
|
stopLoopPolling();
|
|
450
488
|
};
|
|
451
489
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- options are stable from parent
|
|
452
|
-
return { status, activeIssue, queue, completed, logEntries, error, abort };
|
|
490
|
+
return { status, activeIssue, queue, completed, logEntries, loopMonitor, error, abort };
|
|
453
491
|
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AgentScreen - TUI dashboard for the autonomous agent mode
|
|
3
3
|
*
|
|
4
|
-
* Displays issue processing status and an agent log.
|
|
4
|
+
* Displays issue processing status, a loop monitor, and an agent log.
|
|
5
5
|
* Two-column layout on wide terminals (>=65 cols), single-column on narrow.
|
|
6
6
|
*
|
|
7
|
+
* When a loop is running, the right panel splits: loop monitor (top) + log (bottom).
|
|
8
|
+
* When no loop is running, the right panel shows only the agent log.
|
|
9
|
+
*
|
|
7
10
|
* Wired to the orchestrator via useAgentOrchestrator hook, which
|
|
8
11
|
* interprets tool calls into structured React state. Console is patched
|
|
9
12
|
* on mount to prevent Ink rendering corruption.
|
|
@@ -2,9 +2,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
/**
|
|
3
3
|
* AgentScreen - TUI dashboard for the autonomous agent mode
|
|
4
4
|
*
|
|
5
|
-
* Displays issue processing status and an agent log.
|
|
5
|
+
* Displays issue processing status, a loop monitor, and an agent log.
|
|
6
6
|
* Two-column layout on wide terminals (>=65 cols), single-column on narrow.
|
|
7
7
|
*
|
|
8
|
+
* When a loop is running, the right panel splits: loop monitor (top) + log (bottom).
|
|
9
|
+
* When no loop is running, the right panel shows only the agent log.
|
|
10
|
+
*
|
|
8
11
|
* Wired to the orchestrator via useAgentOrchestrator hook, which
|
|
9
12
|
* interprets tool calls into structured React state. Console is patched
|
|
10
13
|
* on mount to prevent Ink rendering corruption.
|
|
@@ -14,8 +17,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
14
17
|
import { useEffect, useCallback, useState, useRef } from 'react';
|
|
15
18
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
16
19
|
import { AppShell } from '../components/AppShell.js';
|
|
17
|
-
import {
|
|
20
|
+
import { ActivityFeed } from '../components/ActivityFeed.js';
|
|
21
|
+
import { colors, phase, theme } from '../theme.js';
|
|
18
22
|
import { useAgentOrchestrator } from '../hooks/useAgentOrchestrator.js';
|
|
23
|
+
import { formatNumber } from '../utils/loop-status.js';
|
|
19
24
|
const NARROW_BREAKPOINT = 65;
|
|
20
25
|
const SECTION_CHAR = '\u2500'; // ─
|
|
21
26
|
function phaseLabel(p, activeIssue) {
|
|
@@ -57,12 +62,12 @@ function footerPhase(status, activeIssue, completedCount, cancelling) {
|
|
|
57
62
|
if (status === 'idle')
|
|
58
63
|
return 'Waiting';
|
|
59
64
|
if (status === 'complete')
|
|
60
|
-
return `Done
|
|
65
|
+
return `Done \u2014 ${completedCount} issue${completedCount === 1 ? '' : 's'} processed`;
|
|
61
66
|
if (status === 'error')
|
|
62
67
|
return 'Error';
|
|
63
68
|
if (!activeIssue)
|
|
64
69
|
return 'Starting...';
|
|
65
|
-
const loopDetail = activeIssue.loopPhase ? `
|
|
70
|
+
const loopDetail = activeIssue.loopPhase ? ` \u00b7 ${activeIssue.loopPhase}` : '';
|
|
66
71
|
const iter = activeIssue.loopIterations != null ? ` (iter ${activeIssue.loopIterations})` : '';
|
|
67
72
|
return `#${activeIssue.issueNumber}${loopDetail}${iter}`;
|
|
68
73
|
}
|
|
@@ -77,16 +82,49 @@ function SectionSeparator({ width }) {
|
|
|
77
82
|
* Issues panel content — shared between wide and narrow layouts
|
|
78
83
|
*/
|
|
79
84
|
function IssuesPanel({ activeIssue, queue, completed, panelWidth, }) {
|
|
80
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: colors.yellow, children: "Active Issue" }), activeIssue ? (_jsxs(Box, { flexDirection: "column", marginLeft: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { color: colors.blue, children: ["#", activeIssue.issueNumber] }), _jsxs(Text, { children: [" ", activeIssue.title] })] }), _jsxs(Text, { dimColor: true, children: [phase.active, " ", phaseLabel(activeIssue.phase, activeIssue), activeIssue.loopIterations != null ? ` (iter ${activeIssue.loopIterations})` : ''] })] })) : (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { dimColor: true, children: "No active issue" }) })), _jsx(SectionSeparator, { width: panelWidth }), _jsxs(Text, { bold: true, color: colors.yellow, children: ["Queue", _jsxs(Text, { dimColor: true, children: [" (", queue.length, ")"] })] }), queue.length === 0 ? (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { dimColor: true, children: "Empty" }) })) : (queue.slice(0, 5).map((issue) => (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["#", issue.issueNumber] }), _jsxs(Text, { children: [" ", issue.title] })] }) }, issue.issueNumber)))), queue.length > 5 && (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { dimColor: true, children: ["...and ", queue.length - 5, " more"] }) })), _jsx(SectionSeparator, { width: panelWidth }), _jsxs(Text, { bold: true, color: colors.yellow, children: ["Completed", _jsxs(Text, { dimColor: true, children: [" (", completed.length, ")"] })] }), completed.length === 0 ? (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { dimColor: true, children: "None yet" }) })) : (completed.slice(-5).map((issue) => (_jsxs(Box, { marginLeft: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: issue.error ? colors.pink : colors.green, children: issue.error ? phase.error : phase.complete }), _jsxs(Text, { dimColor: true, children: [" #", issue.issueNumber] }), _jsxs(Text, { children: [" ", issue.title] })] }), issue.prUrl && (_jsxs(Text, { dimColor: true, children: ["
|
|
85
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: colors.yellow, children: "Active Issue" }), activeIssue ? (_jsxs(Box, { flexDirection: "column", marginLeft: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { color: colors.blue, children: ["#", activeIssue.issueNumber] }), _jsxs(Text, { children: [" ", activeIssue.title] })] }), _jsxs(Text, { dimColor: true, children: [phase.active, " ", phaseLabel(activeIssue.phase, activeIssue), activeIssue.loopIterations != null ? ` (iter ${activeIssue.loopIterations})` : ''] })] })) : (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { dimColor: true, children: "No active issue" }) })), _jsx(SectionSeparator, { width: panelWidth }), _jsxs(Text, { bold: true, color: colors.yellow, children: ["Queue", _jsxs(Text, { dimColor: true, children: [" (", queue.length, ")"] })] }), queue.length === 0 ? (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { dimColor: true, children: "Empty" }) })) : (queue.slice(0, 5).map((issue) => (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: ["#", issue.issueNumber] }), _jsxs(Text, { children: [" ", issue.title] })] }) }, issue.issueNumber)))), queue.length > 5 && (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { dimColor: true, children: ["...and ", queue.length - 5, " more"] }) })), _jsx(SectionSeparator, { width: panelWidth }), _jsxs(Text, { bold: true, color: colors.yellow, children: ["Completed", _jsxs(Text, { dimColor: true, children: [" (", completed.length, ")"] })] }), completed.length === 0 ? (_jsx(Box, { marginLeft: 1, children: _jsx(Text, { dimColor: true, children: "None yet" }) })) : (completed.slice(-5).map((issue) => (_jsxs(Box, { marginLeft: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: issue.error ? colors.pink : colors.green, children: issue.error ? phase.error : phase.complete }), _jsxs(Text, { dimColor: true, children: [" #", issue.issueNumber] }), _jsxs(Text, { children: [" ", issue.title] })] }), issue.prUrl && (_jsxs(Text, { dimColor: true, children: [" \\u2514 PR: ", issue.prUrl] }))] }, issue.issueNumber))))] }));
|
|
81
86
|
}
|
|
82
87
|
/**
|
|
83
|
-
* Log panel content — shared between wide and narrow layouts
|
|
88
|
+
* Log panel content — shared between wide and narrow layouts.
|
|
89
|
+
* tailSize controls how many entries to show (shrinks when monitor is visible).
|
|
84
90
|
*/
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const visible = logEntries.slice(-LOG_TAIL_SIZE);
|
|
91
|
+
function LogPanel({ logEntries, tailSize = 20 }) {
|
|
92
|
+
const visible = logEntries.slice(-tailSize);
|
|
88
93
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: colors.yellow, children: "Agent Log" }), visible.length === 0 ? (_jsx(Text, { dimColor: true, children: "Waiting for agent activity..." })) : (visible.map((entry, index) => (_jsx(Box, { children: _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: entry.timestamp.slice(11, 19) }), _jsx(Text, { children: " " }), _jsx(Text, { color: logLevelColor(entry.level), children: entry.message })] }) }, `${entry.timestamp}-${index}`))))] }));
|
|
89
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* ProgressBar — inline progress indicator
|
|
97
|
+
*/
|
|
98
|
+
function ProgressBar({ percent, width = 18 }) {
|
|
99
|
+
const safePercent = Math.max(0, Math.min(100, percent));
|
|
100
|
+
const filled = Math.round((safePercent / 100) * width);
|
|
101
|
+
const empty = Math.max(0, width - filled);
|
|
102
|
+
return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: colors.green, children: '\u2588'.repeat(filled) }), _jsx(Text, { dimColor: true, children: '\u2591'.repeat(empty) })] }));
|
|
103
|
+
}
|
|
104
|
+
function formatDuration(start) {
|
|
105
|
+
const seconds = Math.max(0, Math.floor((Date.now() - start) / 1000));
|
|
106
|
+
const mins = Math.floor(seconds / 60);
|
|
107
|
+
const secs = seconds % 60;
|
|
108
|
+
return `${mins}:${String(secs).padStart(2, '0')}`;
|
|
109
|
+
}
|
|
110
|
+
const PROGRESS_LABEL_WIDTH = 17;
|
|
111
|
+
const padLabel = (label) => label.padEnd(PROGRESS_LABEL_WIDTH);
|
|
112
|
+
/**
|
|
113
|
+
* Loop Monitor panel — shows progress, commits, and activity for the active loop
|
|
114
|
+
*/
|
|
115
|
+
function LoopMonitorPanel({ monitor, featureName, panelWidth, }) {
|
|
116
|
+
const { loopStatus, tasks, branch, recentCommits, activityEvents, startTime } = monitor;
|
|
117
|
+
const totalTokens = loopStatus.tokensInput + loopStatus.tokensOutput + loopStatus.cacheCreate + loopStatus.cacheRead;
|
|
118
|
+
const totalTasks = tasks.tasksDone + tasks.tasksPending;
|
|
119
|
+
const totalE2e = tasks.e2eDone + tasks.e2ePending;
|
|
120
|
+
const totalAll = totalTasks + totalE2e;
|
|
121
|
+
const doneAll = tasks.tasksDone + tasks.e2eDone;
|
|
122
|
+
const percentTasks = totalTasks > 0 ? Math.round((tasks.tasksDone / totalTasks) * 100) : 0;
|
|
123
|
+
const percentE2e = totalE2e > 0 ? Math.round((tasks.e2eDone / totalE2e) * 100) : 0;
|
|
124
|
+
const percentAll = totalAll > 0 ? Math.round((doneAll / totalAll) * 100) : 0;
|
|
125
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: colors.yellow, children: ["Loop Monitor ", _jsxs(Text, { dimColor: true, children: ["\\u2014 ", featureName] })] }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: "Phase: " }), _jsx(Text, { color: colors.yellow, children: loopStatus.phase }), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsx(Text, { children: "Iter: " }), _jsx(Text, { color: colors.green, children: loopStatus.iteration }), _jsxs(Text, { dimColor: true, children: ["/", loopStatus.maxIterations || '-'] }), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsx(Text, { children: "Branch: " }), _jsx(Text, { color: colors.blue, children: branch })] }), totalTokens > 0 && (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { children: "Tokens: " }), _jsx(Text, { color: colors.pink, children: formatNumber(totalTokens) }), _jsxs(Text, { dimColor: true, children: [" (in:", formatNumber(loopStatus.tokensInput), " out:", formatNumber(loopStatus.tokensOutput), " cache:", formatNumber(loopStatus.cacheRead), ")"] }), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsxs(Text, { dimColor: true, children: ["Elapsed: ", formatDuration(startTime)] })] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", alignItems: "center", gap: 1, children: [_jsx(Text, { bold: true, children: padLabel('Implementation:') }), _jsx(ProgressBar, { percent: percentTasks }), _jsxs(Text, { children: [String(percentTasks).padStart(3), "%"] }), _jsxs(Text, { color: colors.green, children: ['\u2713', " ", tasks.tasksDone] }), _jsxs(Text, { color: colors.yellow, children: ['\u25cb', " ", tasks.tasksPending] })] }), totalE2e > 0 && (_jsxs(Box, { flexDirection: "row", alignItems: "center", gap: 1, children: [_jsx(Text, { bold: true, children: padLabel('E2E Tests:') }), _jsx(ProgressBar, { percent: percentE2e }), _jsxs(Text, { children: [String(percentE2e).padStart(3), "%"] }), _jsxs(Text, { color: colors.green, children: ['\u2713', " ", tasks.e2eDone] }), _jsxs(Text, { color: colors.yellow, children: ['\u25cb', " ", tasks.e2ePending] })] })), _jsxs(Box, { flexDirection: "row", alignItems: "center", gap: 1, children: [_jsx(Text, { bold: true, children: padLabel('Overall:') }), _jsx(ProgressBar, { percent: percentAll }), _jsxs(Text, { children: [String(percentAll).padStart(3), "%"] }), _jsxs(Text, { color: colors.green, children: ['\u2713', " ", doneAll] }), _jsxs(Text, { color: colors.yellow, children: ['\u25cb', " ", totalAll - doneAll] })] })] }), recentCommits.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Recent Commits" }), recentCommits.map((c) => (_jsx(Box, { flexDirection: "row", children: _jsxs(Text, { dimColor: true, children: [" ", c.hash, " ", c.title] }) }, c.hash)))] })), activityEvents.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Activity" }), _jsx(ActivityFeed, { events: activityEvents, maxEvents: 6 })] }))] }));
|
|
126
|
+
}
|
|
127
|
+
const REVIEW_MODES = ['manual', 'auto', 'merge'];
|
|
90
128
|
export function AgentScreen({ header, projectRoot, agentOptions, onExit, }) {
|
|
91
129
|
const { stdout } = useStdout();
|
|
92
130
|
const columns = stdout?.columns ?? 80;
|
|
@@ -107,7 +145,7 @@ export function AgentScreen({ header, projectRoot, agentOptions, onExit, }) {
|
|
|
107
145
|
restore?.();
|
|
108
146
|
};
|
|
109
147
|
}, []);
|
|
110
|
-
const { status, activeIssue, queue, completed, logEntries, error, abort } = useAgentOrchestrator({
|
|
148
|
+
const { status, activeIssue, queue, completed, logEntries, loopMonitor, error, abort } = useAgentOrchestrator({
|
|
111
149
|
projectRoot,
|
|
112
150
|
modelOverride: agentOptions?.modelOverride,
|
|
113
151
|
maxItems: agentOptions?.maxItems,
|
|
@@ -118,6 +156,7 @@ export function AgentScreen({ header, projectRoot, agentOptions, onExit, }) {
|
|
|
118
156
|
});
|
|
119
157
|
// Track whether the user requested cancellation
|
|
120
158
|
const [cancelling, setCancelling] = useState(false);
|
|
159
|
+
const [reviewMode, setReviewMode] = useState(agentOptions?.reviewMode ?? 'manual');
|
|
121
160
|
const onExitRef = useRef(onExit);
|
|
122
161
|
onExitRef.current = onExit;
|
|
123
162
|
// When cancelling and the orchestrator finishes, exit
|
|
@@ -127,6 +166,7 @@ export function AgentScreen({ header, projectRoot, agentOptions, onExit, }) {
|
|
|
127
166
|
}
|
|
128
167
|
}, [cancelling, status]);
|
|
129
168
|
const isWorking = status === 'running' || cancelling;
|
|
169
|
+
const hasLoopMonitor = loopMonitor !== null && activeIssue?.phase === 'running_loop';
|
|
130
170
|
useInput(useCallback((input, key) => {
|
|
131
171
|
if (input === 'q' || key.escape) {
|
|
132
172
|
if (status === 'running') {
|
|
@@ -138,22 +178,32 @@ export function AgentScreen({ header, projectRoot, agentOptions, onExit, }) {
|
|
|
138
178
|
// Already done — exit immediately
|
|
139
179
|
onExitRef.current?.();
|
|
140
180
|
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// Shift+R cycles review mode (manual → auto → merge)
|
|
184
|
+
if (input === 'R') {
|
|
185
|
+
setReviewMode((prev) => {
|
|
186
|
+
const idx = REVIEW_MODES.indexOf(prev);
|
|
187
|
+
return REVIEW_MODES[(idx + 1) % REVIEW_MODES.length];
|
|
188
|
+
});
|
|
141
189
|
}
|
|
142
190
|
}, [abort, status]));
|
|
143
|
-
const tips = cancelling
|
|
191
|
+
const tips = cancelling
|
|
192
|
+
? 'Cancelling...'
|
|
193
|
+
: 'q exit \u2502 Esc back \u2502 Shift+R review mode';
|
|
194
|
+
const footerStatus = {
|
|
195
|
+
action: 'Agent',
|
|
196
|
+
phase: footerPhase(status, activeIssue, completed.length, cancelling),
|
|
197
|
+
extra: `review: ${reviewMode}`,
|
|
198
|
+
};
|
|
144
199
|
if (isNarrow) {
|
|
145
200
|
const panelWidth = Math.max(20, columns - 4);
|
|
146
|
-
return (_jsx(AppShell, { header: header, tips: tips, isWorking: isWorking, workingStatus: workingLabel(activeIssue), footerStatus: {
|
|
147
|
-
action: 'Agent',
|
|
148
|
-
phase: footerPhase(status, activeIssue, completed.length, cancelling),
|
|
149
|
-
}, children: _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.brown, paddingX: 1, children: _jsx(IssuesPanel, { activeIssue: activeIssue, queue: queue, completed: completed, panelWidth: panelWidth }) }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.brown, paddingX: 1, children: _jsx(LogPanel, { logEntries: logEntries }) })] }) }));
|
|
201
|
+
return (_jsx(AppShell, { header: header, tips: tips, isWorking: isWorking, workingStatus: workingLabel(activeIssue), footerStatus: footerStatus, children: _jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.brown, paddingX: 1, children: _jsx(IssuesPanel, { activeIssue: activeIssue, queue: queue, completed: completed, panelWidth: panelWidth }) }), hasLoopMonitor && (_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.brown, paddingX: 1, children: _jsx(LoopMonitorPanel, { monitor: loopMonitor, featureName: activeIssue.loopFeatureName ?? `issue-${activeIssue.issueNumber}`, panelWidth: panelWidth }) })), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.brown, paddingX: 1, children: _jsx(LogPanel, { logEntries: logEntries, tailSize: hasLoopMonitor ? 8 : 20 }) })] }) }));
|
|
150
202
|
}
|
|
151
203
|
// Wide layout: two-column
|
|
152
204
|
const leftWidth = Math.max(30, Math.floor(columns * 0.4));
|
|
153
205
|
const rightWidth = Math.max(30, columns - leftWidth - 3);
|
|
154
206
|
const leftInnerWidth = leftWidth - 4; // border (2) + paddingX (2)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
phase: footerPhase(status, activeIssue, completed.length, cancelling),
|
|
158
|
-
}, children: _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Box, { flexDirection: "column", width: leftWidth, borderStyle: "round", borderColor: colors.brown, paddingX: 1, children: _jsx(IssuesPanel, { activeIssue: activeIssue, queue: queue, completed: completed, panelWidth: leftInnerWidth }) }), _jsx(Box, { flexDirection: "column", width: rightWidth, borderStyle: "round", borderColor: colors.brown, paddingX: 1, children: _jsx(LogPanel, { logEntries: logEntries }) })] }) }));
|
|
207
|
+
const rightInnerWidth = rightWidth - 4;
|
|
208
|
+
return (_jsx(AppShell, { header: header, tips: tips, isWorking: isWorking, workingStatus: workingLabel(activeIssue), footerStatus: footerStatus, children: _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(Box, { flexDirection: "column", width: leftWidth, borderStyle: "round", borderColor: colors.brown, paddingX: 1, children: _jsx(IssuesPanel, { activeIssue: activeIssue, queue: queue, completed: completed, panelWidth: leftInnerWidth }) }), _jsxs(Box, { flexDirection: "column", width: rightWidth, children: [hasLoopMonitor && (_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.brown, paddingX: 1, marginBottom: 1, children: _jsx(LoopMonitorPanel, { monitor: loopMonitor, featureName: activeIssue.loopFeatureName ?? `issue-${activeIssue.issueNumber}`, panelWidth: rightInnerWidth }) })), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.brown, paddingX: 1, children: _jsx(LogPanel, { logEntries: logEntries, tailSize: hasLoopMonitor ? 8 : 20 }) })] })] }) }));
|
|
159
209
|
}
|
|
@@ -7,9 +7,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
7
7
|
* Wrapped in AppShell for consistent layout.
|
|
8
8
|
*/
|
|
9
9
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
|
10
|
-
import { Box, Text, useInput, useApp } from 'ink';
|
|
10
|
+
import { Box, Text, useInput, useApp, useStdout } from 'ink';
|
|
11
11
|
import { MessageList } from '../components/MessageList.js';
|
|
12
12
|
import { ChatInput } from '../components/ChatInput.js';
|
|
13
|
+
import { IssuePicker } from '../components/IssuePicker.js';
|
|
13
14
|
import { ActionOutput } from '../components/ActionOutput.js';
|
|
14
15
|
import { AppShell } from '../components/AppShell.js';
|
|
15
16
|
import { colors, theme, phase } from '../theme.js';
|
|
@@ -18,7 +19,18 @@ import { logger } from '../../utils/logger.js';
|
|
|
18
19
|
import { parseInput, resolveCommandAlias, formatHelpText, } from '../../repl/command-parser.js';
|
|
19
20
|
import { readLoopStatus } from '../utils/loop-status.js';
|
|
20
21
|
import { useSync } from '../hooks/useSync.js';
|
|
22
|
+
import { isGhInstalled, detectGitHubRemote, listRepoIssues, } from '../../utils/github.js';
|
|
23
|
+
import { clearScreen } from '../utils/clear-screen.js';
|
|
21
24
|
import path from 'node:path';
|
|
25
|
+
function slugifyIssueTitle(title, maxWords = 4) {
|
|
26
|
+
return title
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
29
|
+
.trim()
|
|
30
|
+
.split(/\s+/)
|
|
31
|
+
.slice(0, maxWords)
|
|
32
|
+
.join('-') || 'untitled';
|
|
33
|
+
}
|
|
22
34
|
/**
|
|
23
35
|
* Generate a unique ID for messages
|
|
24
36
|
*/
|
|
@@ -33,6 +45,7 @@ function generateId() {
|
|
|
33
45
|
*/
|
|
34
46
|
export function MainShell({ header, sessionState, onNavigate, backgroundRuns, initialMessage, initialFiles, }) {
|
|
35
47
|
const { exit } = useApp();
|
|
48
|
+
const { stdout } = useStdout();
|
|
36
49
|
const [messages, setMessages] = useState(() => {
|
|
37
50
|
const initial = [];
|
|
38
51
|
if (initialMessage) {
|
|
@@ -48,6 +61,12 @@ export function MainShell({ header, sessionState, onNavigate, backgroundRuns, in
|
|
|
48
61
|
const [contextAge, setContextAge] = useState(null);
|
|
49
62
|
// Sync hook
|
|
50
63
|
const { status: syncStatus, error: syncError, sync } = useSync();
|
|
64
|
+
// Issue picker state
|
|
65
|
+
const [issuePickerVisible, setIssuePickerVisible] = useState(false);
|
|
66
|
+
const [issuePickerIssues, setIssuePickerIssues] = useState([]);
|
|
67
|
+
const [issuePickerLoading, setIssuePickerLoading] = useState(false);
|
|
68
|
+
const [issuePickerError, setIssuePickerError] = useState();
|
|
69
|
+
const [issuePickerRepo, setIssuePickerRepo] = useState(null);
|
|
51
70
|
const addSystemMessage = useCallback((content) => {
|
|
52
71
|
const message = {
|
|
53
72
|
id: generateId(),
|
|
@@ -203,6 +222,60 @@ export function MainShell({ header, sessionState, onNavigate, backgroundRuns, in
|
|
|
203
222
|
}
|
|
204
223
|
onNavigate('agent', { dryRun, maxItems, reviewMode });
|
|
205
224
|
}, [sessionState.initialized, addSystemMessage, onNavigate]);
|
|
225
|
+
const handleIssueCommand = useCallback(async (searchQuery) => {
|
|
226
|
+
if (!sessionState.initialized) {
|
|
227
|
+
addSystemMessage('Project not initialized. Run /init first.');
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
setIssuePickerVisible(true);
|
|
231
|
+
setIssuePickerLoading(true);
|
|
232
|
+
setIssuePickerError(undefined);
|
|
233
|
+
try {
|
|
234
|
+
const ghAvailable = await isGhInstalled();
|
|
235
|
+
if (!ghAvailable) {
|
|
236
|
+
setIssuePickerError('Install GitHub CLI (gh) for issue browsing');
|
|
237
|
+
setIssuePickerLoading(false);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
let repo = issuePickerRepo;
|
|
241
|
+
if (!repo) {
|
|
242
|
+
repo = await detectGitHubRemote(sessionState.projectRoot);
|
|
243
|
+
if (!repo) {
|
|
244
|
+
setIssuePickerError('No GitHub remote detected in this project');
|
|
245
|
+
setIssuePickerLoading(false);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
setIssuePickerRepo(repo);
|
|
249
|
+
}
|
|
250
|
+
const result = await listRepoIssues(repo.owner, repo.repo, searchQuery);
|
|
251
|
+
if (result.error) {
|
|
252
|
+
setIssuePickerError(result.error);
|
|
253
|
+
}
|
|
254
|
+
setIssuePickerIssues(result.issues);
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
setIssuePickerError(err instanceof Error ? err.message : String(err));
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
setIssuePickerLoading(false);
|
|
261
|
+
}
|
|
262
|
+
}, [sessionState.initialized, sessionState.projectRoot, issuePickerRepo, addSystemMessage]);
|
|
263
|
+
const handleIssueSelect = useCallback((issue) => {
|
|
264
|
+
clearScreen(stdout);
|
|
265
|
+
setIssuePickerVisible(false);
|
|
266
|
+
setIssuePickerIssues([]);
|
|
267
|
+
const featureName = slugifyIssueTitle(issue.title);
|
|
268
|
+
onNavigate('interview', {
|
|
269
|
+
featureName,
|
|
270
|
+
initialReferences: [`issue:${issue.number}`],
|
|
271
|
+
});
|
|
272
|
+
}, [stdout, onNavigate]);
|
|
273
|
+
const handleIssueCancel = useCallback(() => {
|
|
274
|
+
clearScreen(stdout);
|
|
275
|
+
setIssuePickerVisible(false);
|
|
276
|
+
setIssuePickerIssues([]);
|
|
277
|
+
setIssuePickerError(undefined);
|
|
278
|
+
}, [stdout]);
|
|
206
279
|
const handleConfig = useCallback((args) => {
|
|
207
280
|
if (args.length === 0) {
|
|
208
281
|
addSystemMessage('Config management - not yet implemented in TUI mode. Use CLI: wiggum config');
|
|
@@ -252,6 +325,9 @@ export function MainShell({ header, sessionState, onNavigate, backgroundRuns, in
|
|
|
252
325
|
case 'monitor':
|
|
253
326
|
handleMonitor(args);
|
|
254
327
|
break;
|
|
328
|
+
case 'issue':
|
|
329
|
+
handleIssueCommand(args.join(' ') || undefined);
|
|
330
|
+
break;
|
|
255
331
|
case 'agent':
|
|
256
332
|
handleAgent(args);
|
|
257
333
|
break;
|
|
@@ -264,7 +340,7 @@ export function MainShell({ header, sessionState, onNavigate, backgroundRuns, in
|
|
|
264
340
|
default:
|
|
265
341
|
addSystemMessage(`Unknown command: ${commandName}`);
|
|
266
342
|
}
|
|
267
|
-
}, [handleHelp, handleInit, handleSync, handleNew, handleRun, handleMonitor, handleAgent, handleConfig, handleExit, addSystemMessage]);
|
|
343
|
+
}, [handleHelp, handleInit, handleSync, handleNew, handleRun, handleMonitor, handleIssueCommand, handleAgent, handleConfig, handleExit, addSystemMessage]);
|
|
268
344
|
const handleNaturalLanguage = useCallback((_text) => {
|
|
269
345
|
addSystemMessage('Tip: Use /help to see available commands, or /new <feature> to create a spec.');
|
|
270
346
|
}, [addSystemMessage]);
|
|
@@ -299,10 +375,10 @@ export function MainShell({ header, sessionState, onNavigate, backgroundRuns, in
|
|
|
299
375
|
});
|
|
300
376
|
// Build tips text
|
|
301
377
|
const tips = sessionState.initialized
|
|
302
|
-
? 'Tip: /new <feature> to
|
|
378
|
+
? 'Tip: /new <feature> or /issue to browse issues, /help for commands'
|
|
303
379
|
: 'Tip: /init to set up, /help for commands';
|
|
304
380
|
const specSuggestions = useMemo(() => sessionState.specNames?.map((name) => ({ name, description: '' })), [sessionState.specNames]);
|
|
305
|
-
const inputElement = (_jsx(ChatInput, { onSubmit: handleSubmit, disabled:
|
|
381
|
+
const inputElement = (_jsxs(Box, { flexDirection: "column", children: [_jsx(ChatInput, { onSubmit: handleSubmit, disabled: issuePickerVisible, placeholder: "Enter command or type /help...", onCommand: (cmd) => handleSubmit(`/${cmd}`), specSuggestions: specSuggestions }), issuePickerVisible && (_jsx(IssuePicker, { issues: issuePickerIssues, repoSlug: issuePickerRepo ? `${issuePickerRepo.owner}/${issuePickerRepo.repo}` : '...', onSelect: handleIssueSelect, onCancel: handleIssueCancel, isLoading: issuePickerLoading, error: issuePickerError }))] }));
|
|
306
382
|
return (_jsxs(AppShell, { header: header, tips: tips, isWorking: syncStatus === 'running', workingStatus: syncStatus === 'running' ? 'Syncing project context\u2026' : undefined, input: inputElement, footerStatus: {
|
|
307
383
|
action: projectLabel || 'Main Shell',
|
|
308
384
|
phase: sessionState.provider ? `${sessionState.provider}/${sessionState.model}` : 'No provider',
|
|
@@ -25,13 +25,14 @@ import { ActivityFeed } from '../components/ActivityFeed.js';
|
|
|
25
25
|
import { colors, theme } from '../theme.js';
|
|
26
26
|
import { readLoopStatus, parseImplementationPlan, getGitBranch, formatNumber, getLoopLogPath, parseLoopLog, parsePhaseChanges, } from '../utils/loop-status.js';
|
|
27
27
|
import { buildEnhancedRunSummary } from '../utils/build-run-summary.js';
|
|
28
|
-
import { getCurrentCommitHash } from '../utils/git-summary.js';
|
|
28
|
+
import { getCurrentCommitHash, getRecentCommits } from '../utils/git-summary.js';
|
|
29
29
|
import { writeRunSummaryFile } from '../../utils/summary-file.js';
|
|
30
30
|
import { loadConfigWithDefaults } from '../../utils/config.js';
|
|
31
31
|
import { logger } from '../../utils/logger.js';
|
|
32
32
|
import { readActionRequest, writeActionReply, cleanupActionFiles } from '../utils/action-inbox.js';
|
|
33
33
|
const POLL_INTERVAL_MS = 2500;
|
|
34
34
|
const ERROR_TAIL_LINES = 12;
|
|
35
|
+
const RUN_REVIEW_MODES = ['manual', 'auto', 'merge'];
|
|
35
36
|
function findFeatureLoopScript(projectRoot, scriptsDir) {
|
|
36
37
|
const localScript = join(projectRoot, scriptsDir, 'feature-loop.sh');
|
|
37
38
|
if (existsSync(localScript)) {
|
|
@@ -112,6 +113,9 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
112
113
|
const [activityEvents, setActivityEvents] = useState([]);
|
|
113
114
|
const [latestCommit, setLatestCommit] = useState(null);
|
|
114
115
|
const [baselineCommit, setBaselineCommit] = useState(null);
|
|
116
|
+
const [recentCommits, setRecentCommits] = useState([]);
|
|
117
|
+
const [reviewMode, setReviewMode] = useState(reviewModeProp ?? 'manual');
|
|
118
|
+
const [loopModel, setLoopModel] = useState(null);
|
|
115
119
|
const childRef = useRef(null);
|
|
116
120
|
const stopRequestedRef = useRef(false);
|
|
117
121
|
const isMountedRef = useRef(true);
|
|
@@ -170,6 +174,14 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
170
174
|
else {
|
|
171
175
|
onCancel();
|
|
172
176
|
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// Shift+R cycles review mode (manual → auto → merge)
|
|
180
|
+
if (input === 'R') {
|
|
181
|
+
setReviewMode((prev) => {
|
|
182
|
+
const idx = RUN_REVIEW_MODES.indexOf(prev);
|
|
183
|
+
return RUN_REVIEW_MODES[(idx + 1) % RUN_REVIEW_MODES.length];
|
|
184
|
+
});
|
|
173
185
|
}
|
|
174
186
|
});
|
|
175
187
|
const refreshStatus = useCallback(async () => {
|
|
@@ -184,10 +196,12 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
184
196
|
if (!isMountedRef.current)
|
|
185
197
|
return;
|
|
186
198
|
setBranch(getGitBranch(projectRoot));
|
|
187
|
-
// Update latest commit hash
|
|
199
|
+
// Update latest commit hash and recent commits
|
|
188
200
|
const head = getCurrentCommitHash(projectRoot);
|
|
189
|
-
if (head && isMountedRef.current)
|
|
201
|
+
if (head && isMountedRef.current) {
|
|
190
202
|
setLatestCommit(head);
|
|
203
|
+
setRecentCommits(getRecentCommits(projectRoot, 3));
|
|
204
|
+
}
|
|
191
205
|
// Collect new activity events from log and phase changes
|
|
192
206
|
const logPath = getLoopLogPath(featureName);
|
|
193
207
|
const allLogEvents = parseLoopLog(logPath);
|
|
@@ -336,6 +350,10 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
336
350
|
try {
|
|
337
351
|
const config = sessionState.config ?? await loadConfigWithDefaults(projectRoot);
|
|
338
352
|
specsDirRef.current = config.paths.specs;
|
|
353
|
+
if (!cancelled) {
|
|
354
|
+
setLoopModel(config.loop.defaultModel);
|
|
355
|
+
setReviewMode((prev) => reviewModeProp ?? config.loop.reviewMode ?? prev);
|
|
356
|
+
}
|
|
339
357
|
}
|
|
340
358
|
catch (err) {
|
|
341
359
|
const reason = err instanceof Error ? err.message : String(err);
|
|
@@ -378,6 +396,7 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
378
396
|
configRootRef.current = config.paths.root;
|
|
379
397
|
maxIterationsRef.current = config.loop.maxIterations;
|
|
380
398
|
maxE2eAttemptsRef.current = config.loop.maxE2eAttempts;
|
|
399
|
+
setLoopModel(config.loop.defaultModel);
|
|
381
400
|
const specFile = findSpecFile(projectRoot, featureName, config.paths.specs);
|
|
382
401
|
if (!specFile) {
|
|
383
402
|
setError(`Spec file not found for "${featureName}". Run /new ${featureName} first.`);
|
|
@@ -390,9 +409,10 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
390
409
|
setIsStarting(false);
|
|
391
410
|
return;
|
|
392
411
|
}
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
412
|
+
const effectiveReviewMode = reviewModeProp ?? config.loop.reviewMode ?? 'manual';
|
|
413
|
+
setReviewMode(effectiveReviewMode);
|
|
414
|
+
if (effectiveReviewMode !== 'manual' && effectiveReviewMode !== 'auto' && effectiveReviewMode !== 'merge') {
|
|
415
|
+
setError(`Invalid reviewMode '${effectiveReviewMode}'. Allowed values are 'manual', 'auto', or 'merge'.`);
|
|
396
416
|
setIsStarting(false);
|
|
397
417
|
return;
|
|
398
418
|
}
|
|
@@ -409,7 +429,7 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
409
429
|
String(config.loop.maxIterations),
|
|
410
430
|
String(config.loop.maxE2eAttempts),
|
|
411
431
|
'--review-mode',
|
|
412
|
-
|
|
432
|
+
effectiveReviewMode,
|
|
413
433
|
];
|
|
414
434
|
const child = spawn('bash', [scriptPath, ...args], {
|
|
415
435
|
cwd: dirname(scriptPath),
|
|
@@ -548,8 +568,11 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
548
568
|
: actionRequest
|
|
549
569
|
? 'Select an option, Esc for default'
|
|
550
570
|
: monitorOnly
|
|
551
|
-
? 'Ctrl+C stop, Esc back'
|
|
552
|
-
: 'Ctrl+C stop, Esc background';
|
|
571
|
+
? 'Ctrl+C stop, Esc back, Shift+R review mode'
|
|
572
|
+
: 'Ctrl+C stop, Esc background, Shift+R review mode';
|
|
573
|
+
// Progress bar label padding — align all labels to the longest one
|
|
574
|
+
const PROGRESS_LABEL_WIDTH = 17; // "Implementation: " padded
|
|
575
|
+
const padLabel = (label) => label.padEnd(PROGRESS_LABEL_WIDTH);
|
|
553
576
|
// Action select handler — awaits write before clearing prompt
|
|
554
577
|
const handleActionSelect = useCallback(async (choiceId) => {
|
|
555
578
|
if (!actionRequest)
|
|
@@ -590,7 +613,8 @@ export function RunScreen({ header, featureName, projectRoot, sessionState, moni
|
|
|
590
613
|
action: 'Run Loop',
|
|
591
614
|
phase: phaseLine,
|
|
592
615
|
path: featureName,
|
|
593
|
-
|
|
616
|
+
extra: `review: ${reviewMode}`,
|
|
617
|
+
}, children: completionSummary ? (_jsx(RunCompletionSummary, { summary: completionSummary })) : (!error && (_jsxs(_Fragment, { children: [_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { children: "Phase: " }), _jsx(Text, { color: colors.yellow, children: phaseLine }), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsx(Text, { children: "Iter: " }), _jsx(Text, { color: colors.green, children: status.iteration }), _jsxs(Text, { dimColor: true, children: ["/", status.maxIterations || maxIterationsRef.current || '-'] }), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsx(Text, { children: "Branch: " }), _jsx(Text, { color: colors.blue, children: branch }), loopModel && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsx(Text, { children: "Model: " }), _jsx(Text, { color: colors.blue, children: loopModel })] }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { children: "Tokens: " }), _jsx(Text, { color: colors.pink, children: formatNumber(totalTokens) }), _jsxs(Text, { dimColor: true, children: [" (in:", formatNumber(status.tokensInput), " out:", formatNumber(status.tokensOutput), " cache:", formatNumber(status.cacheRead), ")"] }), _jsx(Text, { dimColor: true, children: theme.statusLine.separator }), _jsxs(Text, { dimColor: true, children: ["Elapsed: ", formatDuration(startTimeRef.current)] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", alignItems: "center", gap: 1, children: [_jsx(Text, { bold: true, children: padLabel('Implementation:') }), _jsx(ProgressBar, { percent: percentTasks }), _jsxs(Text, { children: [String(percentTasks).padStart(3), "%"] }), _jsxs(Text, { color: colors.green, children: ['\u2713', " ", tasks.tasksDone] }), _jsxs(Text, { color: colors.yellow, children: ['\u25cb', " ", tasks.tasksPending] })] }), totalE2e > 0 && (_jsxs(Box, { flexDirection: "row", alignItems: "center", gap: 1, children: [_jsx(Text, { bold: true, children: padLabel('E2E Tests:') }), _jsx(ProgressBar, { percent: percentE2e }), _jsxs(Text, { children: [String(percentE2e).padStart(3), "%"] }), _jsxs(Text, { color: colors.green, children: ['\u2713', " ", tasks.e2eDone] }), _jsxs(Text, { color: colors.yellow, children: ['\u25cb', " ", tasks.e2ePending] })] })), _jsxs(Box, { flexDirection: "row", alignItems: "center", gap: 1, marginTop: 1, children: [_jsx(Text, { bold: true, children: padLabel('Overall:') }), _jsx(ProgressBar, { percent: percentAll }), _jsxs(Text, { children: [String(percentAll).padStart(3), "%"] }), _jsxs(Text, { color: colors.green, children: ['\u2713', " ", doneAll] }), _jsxs(Text, { color: colors.yellow, children: ['\u25cb', " ", totalAll - doneAll] })] })] }), recentCommits.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Recent Commits" }), recentCommits.map((c) => (_jsx(Box, { flexDirection: "row", children: _jsxs(Text, { dimColor: true, children: [" ", c.hash, " ", c.title] }) }, c.hash)))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Activity" }), _jsx(ActivityFeed, { events: activityEvents, latestCommit: baselineCommit && latestCommit && baselineCommit !== latestCommit
|
|
594
618
|
? `${baselineCommit} \u2192 ${latestCommit}`
|
|
595
619
|
: latestCommit || undefined })] })] }))) }));
|
|
596
620
|
}
|
|
@@ -34,4 +34,5 @@ export declare function getDiffStats(projectRoot: string, fromHash: string, toHa
|
|
|
34
34
|
* @param toHash - Ending commit hash (inclusive)
|
|
35
35
|
* @returns Array of commit entries, or null if not available
|
|
36
36
|
*/
|
|
37
|
+
export declare function getRecentCommits(projectRoot: string, count?: number): CommitLogEntry[];
|
|
37
38
|
export declare function getCommitList(projectRoot: string, fromHash: string, toHash: string): CommitLogEntry[] | null;
|
|
@@ -69,6 +69,22 @@ export function getDiffStats(projectRoot, fromHash, toHash) {
|
|
|
69
69
|
* @param toHash - Ending commit hash (inclusive)
|
|
70
70
|
* @returns Array of commit entries, or null if not available
|
|
71
71
|
*/
|
|
72
|
+
export function getRecentCommits(projectRoot, count = 3) {
|
|
73
|
+
try {
|
|
74
|
+
const output = execFileSync('git', ['log', '--oneline', `-${count}`], { cwd: projectRoot, encoding: 'utf-8', timeout: 10_000 }).trim();
|
|
75
|
+
if (!output)
|
|
76
|
+
return [];
|
|
77
|
+
return output.split('\n').map((line) => {
|
|
78
|
+
const spaceIdx = line.indexOf(' ');
|
|
79
|
+
if (spaceIdx === -1)
|
|
80
|
+
return { hash: line, title: '' };
|
|
81
|
+
return { hash: line.substring(0, spaceIdx), title: line.substring(spaceIdx + 1) };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
72
88
|
export function getCommitList(projectRoot, fromHash, toHash) {
|
|
73
89
|
try {
|
|
74
90
|
const output = execFileSync('git', ['log', '--oneline', `${fromHash}..${toHash}`], {
|