@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.
- package/.claude/skills/specify/SKILL.md +2 -2
- package/CHANGELOG.md +59 -0
- package/package.json +1 -1
- package/src/agents/amp.ts +3 -6
- package/src/agents/claude.ts +4 -7
- package/src/agents/codex.ts +3 -6
- package/src/agents/droid.ts +3 -6
- package/src/agents/exec.ts +56 -10
- package/src/agents/gemini.ts +3 -6
- package/src/agents/opencode.ts +3 -6
- package/src/agents/types.ts +2 -0
- package/src/config/schema.ts +2 -2
- package/src/tui/App.tsx +132 -16
- package/src/tui/TUIRunner.tsx +68 -4
- package/src/tui/components/CostBadge.tsx +59 -0
- package/src/tui/components/MessageItem.tsx +113 -0
- package/src/tui/components/MessageQueuePanel.tsx +126 -0
- package/src/tui/components/OutputPanel.tsx +270 -0
- package/src/tui/components/QueueInput.tsx +28 -11
- package/src/tui/components/RateLimitIndicator.tsx +97 -0
- package/src/tui/components/StatusBar.tsx +188 -0
- package/src/tui/components/TaskItem.tsx +131 -0
- package/src/tui/components/TaskPanel.tsx +189 -0
- package/src/tui/components/TokenCounter.tsx +48 -0
- package/src/tui/hooks/useAnimation.ts +220 -0
- package/src/tui/hooks/useCostTracking.ts +199 -0
- package/src/tui/hooks/useResponsiveLayout.ts +94 -0
- package/src/tui/hooks/useTUI.ts +57 -1
- package/src/tui/index.tsx +24 -0
- package/src/tui/layouts/LayoutSwitcher.tsx +95 -0
- package/src/tui/layouts/ThreeColumnLayout.tsx +97 -0
- package/src/tui/layouts/VerticalLayout.tsx +69 -0
- package/src/tui/layouts/index.ts +9 -0
- package/src/tui/theme.ts +152 -21
- package/src/tui/types.ts +95 -0
|
@@ -138,23 +138,40 @@ interface QueueInputProps {
|
|
|
138
138
|
/**
|
|
139
139
|
* Queue Input Component
|
|
140
140
|
*
|
|
141
|
-
* Displays
|
|
142
|
-
*
|
|
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
|
-
|
|
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
|
|
153
|
-
<
|
|
154
|
-
{
|
|
155
|
-
</
|
|
156
|
-
<
|
|
157
|
-
|
|
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
|
+
}
|