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.
@@ -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: interviewProps?.initialReferences, onComplete: handleInterviewComplete, onCancel: handleInterviewCancel }));
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 columns = stdout?.columns ?? 80;
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), columns - 6);
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
- // Space budget for title: total width minus number, labels, borders, padding
53
- const numberWidth = 6; // " #999 " enough for 3-digit issues
54
- const labelBudget = 24; // space for up to 2 labels
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 numberText = `#${issue.number}`;
60
- const title = truncate(issue.title, titleMaxLen);
61
- const labelTags = issue.labels.slice(0, MAX_LABELS).join(' ');
62
- 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: [' ', numberText] }), _jsxs(Text, { backgroundColor: isSelected ? colors.yellow : undefined, color: isSelected ? colors.brown : undefined, children: [' ', title] }), labelTags && (_jsxs(Text, { backgroundColor: isSelected ? colors.yellow : undefined, color: isSelected ? colors.brown : colors.gray, children: [' ', labelTags] })), _jsxs(Text, { dimColor: true, children: [' '.repeat(1), box.vertical] })] }, issue.number));
63
- }), _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
+ 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
- const poll = () => {
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 phaseLabel = readCurrentPhase(featureName);
273
- if (phaseLabel) {
274
- setActiveIssue((prev) => prev ? { ...prev, loopPhase: phaseLabel } : prev);
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
- const loopStatus = readLoopStatus(featureName);
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 { colors, phase } from '../theme.js';
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 ${completedCount} issue${completedCount === 1 ? '' : 's'} processed`;
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 ? ` · ${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: [" \u2514 PR: ", issue.prUrl] }))] }, issue.issueNumber))))] }));
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
- const LOG_TAIL_SIZE = 20;
86
- function LogPanel({ logEntries }) {
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 ? 'Cancelling...' : 'q exit │ Esc back';
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
- return (_jsx(AppShell, { header: header, tips: tips, isWorking: isWorking, workingStatus: workingLabel(activeIssue), footerStatus: {
156
- action: 'Agent',
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 create spec, /help for commands'
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: false, placeholder: "Enter command or type /help...", onCommand: (cmd) => handleSubmit(`/${cmd}`), specSuggestions: specSuggestions }));
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 reviewMode = reviewModeProp ?? config.loop.reviewMode ?? 'manual';
394
- if (reviewMode !== 'manual' && reviewMode !== 'auto' && reviewMode !== 'merge') {
395
- setError(`Invalid reviewMode '${reviewMode}'. Allowed values are 'manual', 'auto', or 'merge'.`);
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
- reviewMode,
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
- }, 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 })] }), _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: "Implementation:" }), _jsx(ProgressBar, { percent: percentTasks }), _jsxs(Text, { children: [percentTasks, "%"] }), _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: "E2E Tests:" }), _jsx(ProgressBar, { percent: percentE2e }), _jsxs(Text, { children: [percentE2e, "%"] }), _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: "Overall:" }), _jsx(ProgressBar, { percent: percentAll }), _jsxs(Text, { children: [percentAll, "%"] }), _jsxs(Text, { color: colors.green, children: ['\u2713', " ", doneAll] }), _jsxs(Text, { color: colors.yellow, children: ['\u25cb', " ", totalAll - doneAll] })] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Activity" }), _jsx(ActivityFeed, { events: activityEvents, latestCommit: baselineCommit && latestCommit && baselineCommit !== latestCommit
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}`], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiggum-cli",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "AI-powered feature development loop CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",