@arvorco/relentless 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -138,23 +138,40 @@ interface QueueInputProps {
138
138
  /**
139
139
  * Queue Input Component
140
140
  *
141
- * Displays an input field at the bottom of the TUI for adding items to the queue.
142
- * Only visible when active is true.
141
+ * Displays a full-width input field at the bottom of the TUI for adding items to the queue.
142
+ * Styled like Claude Code's input area with horizontal borders.
143
143
  */
144
144
  export function QueueInput({ active, value }: QueueInputProps): React.ReactElement | null {
145
145
  if (!active) {
146
- return null;
146
+ // Show hint when not active
147
+ return (
148
+ <Box flexDirection="column" width="100%">
149
+ <Box width="100%">
150
+ <Text color={colors.dim}>{"─".repeat(80)}</Text>
151
+ </Box>
152
+ <Box paddingX={1}>
153
+ <Text color={colors.dim}>Press 'q' to add to queue, 'd' to delete, 'D' to clear</Text>
154
+ </Box>
155
+ <Box width="100%">
156
+ <Text color={colors.dim}>{"─".repeat(80)}</Text>
157
+ </Box>
158
+ </Box>
159
+ );
147
160
  }
148
161
 
149
- const formatted = formatQueueInput(value);
150
-
151
162
  return (
152
- <Box paddingX={1}>
153
- <Text color={colors.accent} bold>
154
- {formatted.prompt}{" "}
155
- </Text>
156
- <Text>{formatted.value}</Text>
157
- <Text color={colors.dim}>{formatted.cursor}</Text>
163
+ <Box flexDirection="column" width="100%">
164
+ <Box width="100%">
165
+ <Text color={colors.accent}>{"".repeat(80)}</Text>
166
+ </Box>
167
+ <Box paddingX={1} width="100%">
168
+ <Text color={colors.accent} bold>❯ </Text>
169
+ <Text>{value}</Text>
170
+ <Text color={colors.accent}>█</Text>
171
+ </Box>
172
+ <Box width="100%">
173
+ <Text color={colors.accent}>{"─".repeat(80)}</Text>
174
+ </Box>
158
175
  </Box>
159
176
  );
160
177
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * RateLimitIndicator Component
3
+ *
4
+ * Rate limit countdown display for agents
5
+ */
6
+
7
+ import React from "react";
8
+ import { Box, Text } from "ink";
9
+ import { colors, symbols } from "../theme.js";
10
+ import { useCountdown } from "../hooks/useAnimation.js";
11
+ import type { AgentState } from "../types.js";
12
+
13
+ interface RateLimitIndicatorProps {
14
+ /** All agent states */
15
+ agents: AgentState[];
16
+ /** Whether to show compact view */
17
+ compact?: boolean;
18
+ /** Maximum agents to show */
19
+ maxAgents?: number;
20
+ }
21
+
22
+ interface AgentCountdownProps {
23
+ agent: AgentState;
24
+ compact: boolean;
25
+ }
26
+
27
+ function AgentCountdown({ agent, compact }: AgentCountdownProps): React.ReactElement | null {
28
+ const countdown = useCountdown(agent.resetTime, agent.rateLimited);
29
+
30
+ if (!agent.rateLimited || !countdown) {
31
+ return null;
32
+ }
33
+
34
+ if (compact) {
35
+ return (
36
+ <Text color={colors.status.rateLimited}>
37
+ {agent.name.substring(0, 3)}: {countdown.display}
38
+ </Text>
39
+ );
40
+ }
41
+
42
+ return (
43
+ <Box>
44
+ <Text color={colors.status.rateLimited}>
45
+ {symbols.clock} {agent.displayName}: {countdown.display}
46
+ </Text>
47
+ </Box>
48
+ );
49
+ }
50
+
51
+ export function RateLimitIndicator({
52
+ agents,
53
+ compact = false,
54
+ maxAgents = 3,
55
+ }: RateLimitIndicatorProps): React.ReactElement | null {
56
+ // Filter to rate-limited agents only
57
+ const limitedAgents = agents.filter((a) => a.rateLimited);
58
+
59
+ if (limitedAgents.length === 0) {
60
+ return null;
61
+ }
62
+
63
+ const visibleAgents = limitedAgents.slice(0, maxAgents);
64
+ const hasMore = limitedAgents.length > maxAgents;
65
+
66
+ if (compact) {
67
+ return (
68
+ <Box>
69
+ {visibleAgents.map((agent, i) => (
70
+ <React.Fragment key={agent.name}>
71
+ {i > 0 && <Text color={colors.dim}> </Text>}
72
+ <AgentCountdown agent={agent} compact />
73
+ </React.Fragment>
74
+ ))}
75
+ {hasMore && (
76
+ <Text color={colors.dim}> +{limitedAgents.length - maxAgents}</Text>
77
+ )}
78
+ </Box>
79
+ );
80
+ }
81
+
82
+ return (
83
+ <Box flexDirection="column">
84
+ <Text color={colors.status.rateLimited} bold>
85
+ Rate Limited:
86
+ </Text>
87
+ {visibleAgents.map((agent) => (
88
+ <AgentCountdown key={agent.name} agent={agent} compact={false} />
89
+ ))}
90
+ {hasMore && (
91
+ <Text color={colors.dim}>
92
+ +{limitedAgents.length - maxAgents} more
93
+ </Text>
94
+ )}
95
+ </Box>
96
+ );
97
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * StatusBar Component
3
+ *
4
+ * Bottom bar showing real-time metrics: cost, tokens, mode, rate limits, iteration, time, savings
5
+ */
6
+
7
+ import React from "react";
8
+ import { Box, Text } from "ink";
9
+ import { colors, borders, badges, symbols } from "../theme.js";
10
+ import { CostBadge } from "./CostBadge.js";
11
+ import { TokenCounter } from "./TokenCounter.js";
12
+ import { RateLimitIndicator } from "./RateLimitIndicator.js";
13
+ import type { AgentState, CostData, TokenUsage } from "../types.js";
14
+ import type { AgentName } from "../../agents/types.js";
15
+
16
+ interface StatusBarProps {
17
+ /** Cost tracking data */
18
+ costData?: CostData;
19
+ /** Token usage */
20
+ tokens?: TokenUsage;
21
+ /** Current iteration */
22
+ iteration: number;
23
+ /** Maximum iterations */
24
+ maxIterations: number;
25
+ /** All agent states */
26
+ agents: AgentState[];
27
+ /** Total elapsed seconds */
28
+ elapsedSeconds: number;
29
+ /** Current mode */
30
+ mode?: "free" | "cheap" | "good" | "genius";
31
+ /** Current complexity */
32
+ complexity?: "simple" | "medium" | "complex" | "expert";
33
+ /** Current harness/agent */
34
+ harness?: AgentName;
35
+ /** Current model */
36
+ model?: string;
37
+ /** Savings percentage vs SOTA */
38
+ savingsPercent?: number;
39
+ /** Completed stories count */
40
+ completedStories?: number;
41
+ /** Total stories count */
42
+ totalStories?: number;
43
+ }
44
+
45
+ /**
46
+ * Format elapsed time as MM:SS or HH:MM:SS
47
+ */
48
+ function formatElapsedTime(seconds: number): string {
49
+ const hours = Math.floor(seconds / 3600);
50
+ const minutes = Math.floor((seconds % 3600) / 60);
51
+ const secs = seconds % 60;
52
+
53
+ if (hours > 0) {
54
+ return `${hours}h ${minutes.toString().padStart(2, "0")}m`;
55
+ }
56
+
57
+ return `${minutes}m ${secs.toString().padStart(2, "0")}s`;
58
+ }
59
+
60
+ /**
61
+ * Separator component
62
+ */
63
+ function Separator(): React.ReactElement {
64
+ return (
65
+ <Text color={colors.dim}> {borders.vertical} </Text>
66
+ );
67
+ }
68
+
69
+ export function StatusBar({
70
+ costData,
71
+ tokens,
72
+ iteration,
73
+ maxIterations,
74
+ agents,
75
+ elapsedSeconds,
76
+ mode,
77
+ complexity,
78
+ harness,
79
+ model,
80
+ savingsPercent,
81
+ completedStories,
82
+ totalStories,
83
+ }: StatusBarProps): React.ReactElement {
84
+ // Get mode badge styling
85
+ const modeBadge = mode ? badges.mode[mode] : null;
86
+ const complexityBadge = complexity ? badges.complexity[complexity] : null;
87
+
88
+ // Check for rate-limited agents
89
+ const hasRateLimits = agents.some((a) => a.rateLimited);
90
+
91
+ return (
92
+ <Box
93
+ width="100%"
94
+ paddingX={1}
95
+ borderStyle="single"
96
+ borderColor={colors.dim}
97
+ justifyContent="space-between"
98
+ >
99
+ {/* Left section: Cost & Tokens */}
100
+ <Box>
101
+ {costData && (
102
+ <>
103
+ <CostBadge
104
+ actual={costData.actual}
105
+ estimated={costData.estimated}
106
+ compact
107
+ />
108
+ <Separator />
109
+ </>
110
+ )}
111
+
112
+ {tokens && (
113
+ <>
114
+ <TokenCounter tokens={tokens} compact />
115
+ <Separator />
116
+ </>
117
+ )}
118
+
119
+ {/* Iteration counter */}
120
+ <Text color={colors.dim}>
121
+ {iteration}/{maxIterations}
122
+ </Text>
123
+
124
+ {/* Stories progress */}
125
+ {completedStories !== undefined && totalStories !== undefined && (
126
+ <>
127
+ <Separator />
128
+ <Text color={completedStories === totalStories ? colors.success : colors.warning}>
129
+ {completedStories}/{totalStories}
130
+ </Text>
131
+ </>
132
+ )}
133
+ </Box>
134
+
135
+ {/* Center section: Mode/Routing info */}
136
+ <Box>
137
+ {modeBadge && (
138
+ <>
139
+ <Text color={modeBadge.color as string} bold>
140
+ {modeBadge.text}
141
+ </Text>
142
+ {complexityBadge && (
143
+ <>
144
+ <Text color={colors.dim}>/</Text>
145
+ <Text color={complexityBadge.color as string}>
146
+ {complexityBadge.text}
147
+ </Text>
148
+ </>
149
+ )}
150
+ </>
151
+ )}
152
+
153
+ {harness && model && (
154
+ <>
155
+ <Text color={colors.dim}> {symbols.arrow} </Text>
156
+ <Text color={colors.primary}>{harness}</Text>
157
+ <Text color={colors.dim}>/</Text>
158
+ <Text color={colors.dim}>{model}</Text>
159
+ </>
160
+ )}
161
+ </Box>
162
+
163
+ {/* Right section: Rate limits, Time, Savings */}
164
+ <Box>
165
+ {/* Rate limit indicators */}
166
+ {hasRateLimits && (
167
+ <>
168
+ <RateLimitIndicator agents={agents} compact maxAgents={1} />
169
+ <Separator />
170
+ </>
171
+ )}
172
+
173
+ {/* Elapsed time */}
174
+ <Text color={colors.dim}>{formatElapsedTime(elapsedSeconds)}</Text>
175
+
176
+ {/* Savings indicator */}
177
+ {savingsPercent !== undefined && savingsPercent > 0 && (
178
+ <>
179
+ <Separator />
180
+ <Text color={colors.success}>
181
+ {savingsPercent}% saved
182
+ </Text>
183
+ </>
184
+ )}
185
+ </Box>
186
+ </Box>
187
+ );
188
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * TaskItem Component
3
+ *
4
+ * Individual task item with status indicator, animation, and metadata
5
+ */
6
+
7
+ import React from "react";
8
+ import { Box, Text } from "ink";
9
+ import { colors, symbols } from "../theme.js";
10
+ import { usePulse } from "../hooks/useAnimation.js";
11
+ import type { Story } from "../types.js";
12
+
13
+ interface TaskItemProps {
14
+ /** Story/task data */
15
+ story: Story;
16
+ /** Whether this is the currently active task */
17
+ isCurrent: boolean;
18
+ /** Whether to show compact view (less details) */
19
+ compact?: boolean;
20
+ /** Maximum width for title truncation */
21
+ maxTitleWidth?: number;
22
+ }
23
+
24
+ /**
25
+ * Get the status symbol and color for a story
26
+ */
27
+ function getStatusIndicator(
28
+ story: Story,
29
+ isCurrent: boolean,
30
+ pulseFrame: string
31
+ ): { symbol: string; color: string } {
32
+ if (isCurrent) {
33
+ return { symbol: pulseFrame, color: colors.warning };
34
+ }
35
+ if (story.blocked) {
36
+ return { symbol: symbols.blocked, color: colors.status.blocked };
37
+ }
38
+ if (story.passes) {
39
+ return { symbol: symbols.complete, color: colors.success };
40
+ }
41
+ return { symbol: symbols.pending, color: colors.dim };
42
+ }
43
+
44
+ /**
45
+ * Get priority badge color
46
+ */
47
+ function getPriorityColor(priority: number): string {
48
+ if (priority <= 2) return colors.error;
49
+ if (priority <= 4) return colors.warning;
50
+ return colors.dim;
51
+ }
52
+
53
+ export function TaskItem({
54
+ story,
55
+ isCurrent,
56
+ compact = false,
57
+ maxTitleWidth = 30,
58
+ }: TaskItemProps): React.ReactElement {
59
+ const pulseFrame = usePulse(isCurrent);
60
+ const { symbol, color } = getStatusIndicator(story, isCurrent, pulseFrame);
61
+
62
+ // Truncate title if needed
63
+ const displayTitle =
64
+ story.title.length > maxTitleWidth
65
+ ? story.title.substring(0, maxTitleWidth - 1) + "\u2026"
66
+ : story.title;
67
+
68
+ if (compact) {
69
+ return (
70
+ <Box flexDirection="row">
71
+ {/* Status indicator */}
72
+ <Text color={color}>{symbol.padEnd(4)}</Text>
73
+
74
+ {/* Story ID */}
75
+ <Text
76
+ color={story.passes ? colors.success : isCurrent ? colors.warning : undefined}
77
+ dimColor={story.passes}
78
+ >
79
+ {story.id}
80
+ </Text>
81
+ </Box>
82
+ );
83
+ }
84
+
85
+ return (
86
+ <Box flexDirection="row" width="100%">
87
+ {/* Status indicator */}
88
+ <Text color={color}>{symbol.padEnd(4)}</Text>
89
+
90
+ {/* Story ID */}
91
+ <Text
92
+ color={story.passes ? colors.success : isCurrent ? colors.warning : undefined}
93
+ dimColor={story.passes}
94
+ bold={isCurrent}
95
+ >
96
+ {story.id.padEnd(9)}
97
+ </Text>
98
+
99
+ {/* Priority badge */}
100
+ <Text color={getPriorityColor(story.priority)} bold={story.priority <= 3}>
101
+ P{story.priority}{" "}
102
+ </Text>
103
+
104
+ {/* Title */}
105
+ <Text
106
+ color={story.passes ? colors.dim : isCurrent ? colors.warning : undefined}
107
+ dimColor={story.passes}
108
+ strikethrough={story.passes}
109
+ wrap="truncate"
110
+ >
111
+ {displayTitle}
112
+ </Text>
113
+
114
+ {/* Metadata badges */}
115
+ {story.research && (
116
+ <Text color={colors.dim}> {symbols.research}</Text>
117
+ )}
118
+
119
+ {story.blocked && !isCurrent && (
120
+ <Text color={colors.status.blocked}> {symbols.blocked}</Text>
121
+ )}
122
+
123
+ {story.phase && (
124
+ <Text color={colors.dim} dimColor>
125
+ {" "}
126
+ [{story.phase}]
127
+ </Text>
128
+ )}
129
+ </Box>
130
+ );
131
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * TaskPanel Component
3
+ *
4
+ * Left panel showing scrollable task list with phase grouping
5
+ */
6
+
7
+ import React, { useMemo } from "react";
8
+ import { Box, Text } from "ink";
9
+ import { colors, symbols } from "../theme.js";
10
+ import { TaskItem } from "./TaskItem.js";
11
+ import type { Story } from "../types.js";
12
+
13
+ interface TaskPanelProps {
14
+ /** All stories */
15
+ stories: Story[];
16
+ /** Currently active story ID */
17
+ currentStoryId?: string;
18
+ /** Maximum visible rows */
19
+ maxRows?: number;
20
+ /** Panel title */
21
+ title?: string;
22
+ /** Whether to show phase headers */
23
+ showPhases?: boolean;
24
+ }
25
+
26
+ interface PhaseGroup {
27
+ phase: string;
28
+ stories: Story[];
29
+ }
30
+
31
+ /**
32
+ * Group stories by phase
33
+ */
34
+ function groupByPhase(stories: Story[]): PhaseGroup[] {
35
+ const groups = new Map<string, Story[]>();
36
+
37
+ for (const story of stories) {
38
+ const phase = story.phase ?? "Tasks";
39
+ if (!groups.has(phase)) {
40
+ groups.set(phase, []);
41
+ }
42
+ groups.get(phase)!.push(story);
43
+ }
44
+
45
+ return Array.from(groups.entries()).map(([phase, stories]) => ({
46
+ phase,
47
+ stories,
48
+ }));
49
+ }
50
+
51
+ /**
52
+ * Calculate scroll window to keep current story visible
53
+ */
54
+ function calculateScrollWindow(
55
+ totalItems: number,
56
+ currentIndex: number,
57
+ maxVisible: number
58
+ ): { start: number; end: number } {
59
+ if (totalItems <= maxVisible) {
60
+ return { start: 0, end: totalItems };
61
+ }
62
+
63
+ // Center the current item in the window
64
+ const half = Math.floor(maxVisible / 2);
65
+ let start = Math.max(0, currentIndex - half);
66
+
67
+ // Adjust if window goes past the end
68
+ if (start + maxVisible > totalItems) {
69
+ start = Math.max(0, totalItems - maxVisible);
70
+ }
71
+
72
+ return { start, end: Math.min(totalItems, start + maxVisible) };
73
+ }
74
+
75
+ export function TaskPanel({
76
+ stories,
77
+ currentStoryId,
78
+ maxRows = 20,
79
+ title = "Tasks",
80
+ showPhases = true,
81
+ }: TaskPanelProps): React.ReactElement {
82
+ // Calculate completion stats
83
+ const completedCount = stories.filter((s) => s.passes).length;
84
+ const totalCount = stories.length;
85
+ const progressPercent =
86
+ totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
87
+
88
+ // Find current story index
89
+ const currentIndex = stories.findIndex((s) => s.id === currentStoryId);
90
+
91
+ // Group stories by phase if enabled
92
+ const phaseGroups = useMemo(() => {
93
+ if (!showPhases) {
94
+ return [{ phase: "", stories }];
95
+ }
96
+ return groupByPhase(stories);
97
+ }, [stories, showPhases]);
98
+
99
+ // Calculate which items to show (with scrolling)
100
+ const { start, end } = calculateScrollWindow(stories.length, currentIndex, maxRows);
101
+ const visibleStories = stories.slice(start, end);
102
+ const hasMore = end < stories.length;
103
+ const hasLess = start > 0;
104
+
105
+ return (
106
+ <Box flexDirection="column" paddingX={1} width="100%">
107
+ {/* Header */}
108
+ <Box marginBottom={1}>
109
+ <Text color={colors.primary} bold>
110
+ {title}
111
+ </Text>
112
+ <Text color={colors.dim}>
113
+ {" "}
114
+ ({completedCount}/{totalCount})
115
+ </Text>
116
+ <Text color={progressPercent === 100 ? colors.success : colors.dim}>
117
+ {" "}
118
+ {progressPercent}%
119
+ </Text>
120
+ </Box>
121
+
122
+ {/* Scroll indicator (top) */}
123
+ {hasLess && (
124
+ <Box>
125
+ <Text color={colors.dim}>
126
+ {symbols.priority.high} {start} more above
127
+ </Text>
128
+ </Box>
129
+ )}
130
+
131
+ {/* Task list */}
132
+ <Box flexDirection="column">
133
+ {showPhases
134
+ ? phaseGroups.map((group) => {
135
+ // Filter visible stories for this phase
136
+ const visibleInPhase = group.stories.filter((s) =>
137
+ visibleStories.includes(s)
138
+ );
139
+ if (visibleInPhase.length === 0) return null;
140
+
141
+ return (
142
+ <Box key={group.phase} flexDirection="column" marginBottom={1}>
143
+ {/* Phase header */}
144
+ {group.phase && (
145
+ <Box marginBottom={0}>
146
+ <Text color={colors.accent} bold>
147
+ [{group.phase}]
148
+ </Text>
149
+ <Text color={colors.dim}>
150
+ {" "}
151
+ ({group.stories.filter((s) => s.passes).length}/
152
+ {group.stories.length})
153
+ </Text>
154
+ </Box>
155
+ )}
156
+
157
+ {/* Stories in phase */}
158
+ {visibleInPhase.map((story) => (
159
+ <TaskItem
160
+ key={story.id}
161
+ story={story}
162
+ isCurrent={story.id === currentStoryId}
163
+ maxTitleWidth={25}
164
+ />
165
+ ))}
166
+ </Box>
167
+ );
168
+ })
169
+ : visibleStories.map((story) => (
170
+ <TaskItem
171
+ key={story.id}
172
+ story={story}
173
+ isCurrent={story.id === currentStoryId}
174
+ maxTitleWidth={25}
175
+ />
176
+ ))}
177
+ </Box>
178
+
179
+ {/* Scroll indicator (bottom) */}
180
+ {hasMore && (
181
+ <Box>
182
+ <Text color={colors.dim}>
183
+ {symbols.priority.low} {stories.length - end} more below
184
+ </Text>
185
+ </Box>
186
+ )}
187
+ </Box>
188
+ );
189
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * TokenCounter Component
3
+ *
4
+ * Token usage display with input/output breakdown
5
+ */
6
+
7
+ import React from "react";
8
+ import { Box, Text } from "ink";
9
+ import { colors } from "../theme.js";
10
+ import { formatTokens } from "../hooks/useCostTracking.js";
11
+ import type { TokenUsage } from "../types.js";
12
+
13
+ interface TokenCounterProps {
14
+ /** Token usage data */
15
+ tokens: TokenUsage;
16
+ /** Whether to show compact view (only total) */
17
+ compact?: boolean;
18
+ }
19
+
20
+ export function TokenCounter({
21
+ tokens,
22
+ compact = false,
23
+ }: TokenCounterProps): React.ReactElement {
24
+ const inputFormatted = formatTokens(tokens.inputTokens);
25
+ const outputFormatted = formatTokens(tokens.outputTokens);
26
+ const totalFormatted = formatTokens(tokens.totalTokens);
27
+
28
+ if (compact) {
29
+ return (
30
+ <Text color={colors.primary}>
31
+ {totalFormatted}
32
+ </Text>
33
+ );
34
+ }
35
+
36
+ return (
37
+ <Box>
38
+ <Text color={colors.dim}>
39
+ {inputFormatted}
40
+ </Text>
41
+ <Text color={colors.dim}> in / </Text>
42
+ <Text color={colors.primary}>
43
+ {outputFormatted}
44
+ </Text>
45
+ <Text color={colors.dim}> out</Text>
46
+ </Box>
47
+ );
48
+ }